diff options
| -rw-r--r-- | README.md | 30 | ||||
| -rwxr-xr-x | bin/sailfish-devel-mcp | 5 | ||||
| -rw-r--r-- | examples/config.json | 8 | ||||
| -rw-r--r-- | src/sailfish_devel_mcp/config.py | 97 | ||||
| -rw-r--r-- | src/sailfish_devel_mcp/server.py | 79 | ||||
| -rw-r--r-- | src/sailfish_devel_mcp/tools.py | 559 | ||||
| -rw-r--r-- | tests/test_server.py | 221 |
7 files changed, 977 insertions, 22 deletions
@@ -23,6 +23,7 @@ The server currently exposes tools for: - user-session command execution with the configured D-Bus environment - Sailfish Browser launch/debug helpers - Docker/mb2 RPM builds through the local `build-sailfishos` helper +- remote Android/AppSupport builds on configured build hosts - installed SDK repository metadata refresh - Jolla OBS result and build-log lookup through `osc` - repository status and search under the configured git root @@ -67,6 +68,12 @@ Example MCP client configuration: } ``` +The wrapper writes startup, shutdown, MCP request, and tool-call logs to +`~/.local/state/sailfish-devel-mcp/server.log` by default. Override the log +directory with `SAILFISH_DEVEL_MCP_LOG_DIR`. If the MCP client uses a different +server label, set `SAILFISH_DEVEL_MCP_SERVER_LABEL` in the client environment so +the wrapper startup line includes the same label. + ## Configuration Example: @@ -90,6 +97,14 @@ Example: "ssh_config": "~/.ssh/config", "local_sdk": "/srv/mer/sdks/sfossdk/sdk-chroot", "osc_api_alias": "your-obs-alias" + }, + "default_android_build_host": "android-builder", + "android_build_hosts": { + "android-builder": { + "ssh_target": "user@android-build-host", + "project_dir": "/path/to/alien-aliendalvik-system", + "state_dir": "/tmp/sailfish-devel-mcp/android-builds" + } } } ``` @@ -112,6 +127,7 @@ Mutating tools are annotated as non-read-only: - `sailfish_device_restart_service` - `sailfish_device_browser_launch` - `sailfish_build_rpm` +- `sailfish_android_build` - `sailfish_sdk_refresh_metadata` `sailfish_device_lipstick_screenshot` defaults to @@ -139,6 +155,11 @@ structured result separately. Set `run_as_user` when the command should execute as the configured Sailfish username through `runuser` or `su`. +`sailfish_device_install_rpm` accepts either `rpm_path` for one local RPM or +`rpm_paths` for a dependency set. With `rpm_paths`, the tool copies every RPM to +the remote `remote_dir` and runs one `pkcon install-local` or `rpm -Uvh` +command with all copied files, so dependencies can be resolved together. + `sailfish_device_browser_launch` stops the browser booster service and stale browser/firejail PIDs when requested, launches Sailfish Browser through `invoker` with the display and user-session environment, then queries Lipstick @@ -156,6 +177,15 @@ wrapper image defaults to `sailfish-sdk-build-engine:$USER` and can be overridden with `SAILFISH_SDK_BUILD_ENGINE_IMAGE`. +`sailfish_android_build` starts a remote Android/AppSupport build on the +configured build host. Configure `android_build_hosts.<host>.project_dir` or +pass `project_dir` to point at the remote Android tree, for example an +`alien-aliendalvik-system` checkout. The tool writes job state under the remote +`state_dir`, creates a per-job `run.sh`, and starts it with `nohup`, so the SSH +session used to launch the job can disconnect without killing the build. Poll +with `sailfish_android_build_status`; omit `job_id` to list recent jobs, or pass +a job id to read state and tail `build.log`. + `sailfish_sdk_refresh_metadata` refreshes zypper metadata in the installed SDK main target, for example `aarch64.default`, using the same privileged Docker wrapper style as local SDK builds. Use it when local SDK builds fail because a diff --git a/bin/sailfish-devel-mcp b/bin/sailfish-devel-mcp index 94a946e..b2741fa 100755 --- a/bin/sailfish-devel-mcp +++ b/bin/sailfish-devel-mcp @@ -6,6 +6,7 @@ PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}" export PYTHONPATH PYTHONUNBUFFERED="${PYTHONUNBUFFERED:-1}" export PYTHONUNBUFFERED +server_label="${SAILFISH_DEVEL_MCP_SERVER_LABEL:-sailfish-devel-mcp}" if [ -n "${SAILFISH_DEVEL_MCP_LOG_DIR:-}" ]; then log_dir=$SAILFISH_DEVEL_MCP_LOG_DIR @@ -56,8 +57,8 @@ if mkdir -p "$log_dir" 2>/dev/null && touch "$log_file" 2>/dev/null; then trap 'status=$?; log_exit_once "$status"' EXIT printf '\n' >&2 - printf '[%s] starting sailfish-devel-mcp wrapper_pid=%s parent_pid=%s' \ - "$(date -Is)" "$$" "${PPID:-unknown}" >&2 + printf '[%s] starting sailfish-devel-mcp server_label=%s wrapper_pid=%s parent_pid=%s' \ + "$(date -Is)" "$server_label" "$$" "${PPID:-unknown}" >&2 if [ "$#" -gt 0 ]; then printf ' argv=' >&2 for arg do diff --git a/examples/config.json b/examples/config.json index 96906ea..97f916f 100644 --- a/examples/config.json +++ b/examples/config.json @@ -16,5 +16,13 @@ "ssh_config": "~/.ssh/config", "local_sdk": "/srv/mer/sdks/sfossdk/sdk-chroot", "osc_api_alias": "your-obs-alias" + }, + "default_android_build_host": "android-builder", + "android_build_hosts": { + "android-builder": { + "ssh_target": "user@android-build-host", + "project_dir": "/path/to/alien-aliendalvik-system", + "state_dir": "/tmp/sailfish-devel-mcp/android-builds" + } } } diff --git a/src/sailfish_devel_mcp/config.py b/src/sailfish_devel_mcp/config.py index 73740d1..198b09d 100644 --- a/src/sailfish_devel_mcp/config.py +++ b/src/sailfish_devel_mcp/config.py @@ -12,6 +12,10 @@ DEFAULT_BUS_ADDRESS = "unix:path=/run/user/100000/dbus/user_bus_socket" BUNDLED_BUILD_HELPER = ( Path(__file__).resolve().parent / "vendor" / "build_sailfishos.py" ) +DEFAULT_ANDROID_BUILD_HOST = "android-builder" +DEFAULT_ANDROID_BUILD_SSH_TARGET = "builder@example.invalid" +DEFAULT_ANDROID_BUILD_PROJECT_DIR = "" +DEFAULT_ANDROID_BUILD_STATE_DIR = "/tmp/sailfish-devel-mcp/android-builds" @dataclass(frozen=True) @@ -37,6 +41,33 @@ class DeviceConfig: @dataclass(frozen=True) +class AndroidBuildHostConfig: + name: str + ssh_target: str + project_dir: str + state_dir: str = DEFAULT_ANDROID_BUILD_STATE_DIR + + def public_dict(self) -> dict[str, object]: + return { + "name": self.name, + "ssh_target": self.ssh_target, + "project_dir": self.project_dir, + "state_dir": self.state_dir, + } + + +def default_android_build_hosts() -> dict[str, AndroidBuildHostConfig]: + return { + DEFAULT_ANDROID_BUILD_HOST: AndroidBuildHostConfig( + name=DEFAULT_ANDROID_BUILD_HOST, + ssh_target=DEFAULT_ANDROID_BUILD_SSH_TARGET, + project_dir=DEFAULT_ANDROID_BUILD_PROJECT_DIR, + state_dir=DEFAULT_ANDROID_BUILD_STATE_DIR, + ) + } + + +@dataclass(frozen=True) class PathConfig: git_root: Path = Path.home() / "git" obs_root: Path = Path.home() / "OBS" @@ -62,6 +93,10 @@ class Config: default_device: str devices: Mapping[str, DeviceConfig] = field(default_factory=dict) paths: PathConfig = field(default_factory=PathConfig) + default_android_build_host: str = DEFAULT_ANDROID_BUILD_HOST + android_build_hosts: Mapping[str, AndroidBuildHostConfig] = field( + default_factory=default_android_build_hosts + ) def device(self, name: str | None = None) -> DeviceConfig: key = name or self.default_device @@ -71,6 +106,18 @@ class Config: return DeviceConfig(name=name, ssh_target=name) raise KeyError(f"unknown Sailfish device: {key}") + def android_build_host(self, name: str | None = None) -> AndroidBuildHostConfig: + key = name or self.default_android_build_host + if key in self.android_build_hosts: + return self.android_build_hosts[key] + if name and "@" in name: + return AndroidBuildHostConfig( + name=name, + ssh_target=name, + project_dir=DEFAULT_ANDROID_BUILD_PROJECT_DIR, + ) + raise KeyError(f"unknown Android build host: {key}") + def public_dict(self) -> dict[str, Any]: return { "path": str(self.path) if self.path else None, @@ -79,6 +126,11 @@ class Config: name: device.public_dict() for name, device in self.devices.items() }, "paths": self.paths.public_dict(), + "default_android_build_host": self.default_android_build_host, + "android_build_hosts": { + name: host.public_dict() + for name, host in self.android_build_hosts.items() + }, } @@ -111,11 +163,17 @@ def load_config(path: str | os.PathLike[str] | None = None) -> Config: default_device = str(raw.get("default_device") or next(iter(devices))) paths = _load_paths(raw.get("paths", {})) + android_build_hosts = _load_android_build_hosts(raw.get("android_build_hosts", {})) + default_android_build_host = str( + raw.get("default_android_build_host") or next(iter(android_build_hosts)) + ) return Config( path=config_path if config_path.exists() else None, default_device=default_device, devices=devices, paths=paths, + default_android_build_host=default_android_build_host, + android_build_hosts=android_build_hosts, ) @@ -144,6 +202,45 @@ def _load_devices(raw_devices: Any) -> dict[str, DeviceConfig]: return devices +def _load_android_build_hosts(raw_hosts: Any) -> dict[str, AndroidBuildHostConfig]: + if not isinstance(raw_hosts, dict): + raise ValueError("android_build_hosts must be a JSON object") + + hosts: dict[str, AndroidBuildHostConfig] = {} + for name, value in raw_hosts.items(): + if isinstance(value, str): + value = {"ssh_target": value} + if not isinstance(value, dict): + raise ValueError(f"android build host {name!r} must be a string or object") + hosts[str(name)] = AndroidBuildHostConfig( + name=str(name), + ssh_target=str(value.get("ssh_target") or name), + project_dir=str( + value.get("project_dir") or DEFAULT_ANDROID_BUILD_PROJECT_DIR + ), + state_dir=str(value.get("state_dir") or DEFAULT_ANDROID_BUILD_STATE_DIR), + ) + + if not hosts: + name = os.environ.get("SAILFISH_MCP_ANDROID_BUILD_HOST", DEFAULT_ANDROID_BUILD_HOST) + hosts[name] = AndroidBuildHostConfig( + name=name, + ssh_target=os.environ.get( + "SAILFISH_MCP_ANDROID_BUILD_SSH_TARGET", + DEFAULT_ANDROID_BUILD_SSH_TARGET, + ), + project_dir=os.environ.get( + "SAILFISH_MCP_ANDROID_BUILD_PROJECT_DIR", + DEFAULT_ANDROID_BUILD_PROJECT_DIR, + ), + state_dir=os.environ.get( + "SAILFISH_MCP_ANDROID_BUILD_STATE_DIR", + DEFAULT_ANDROID_BUILD_STATE_DIR, + ), + ) + return hosts + + def _load_paths(raw_paths: Any) -> PathConfig: if not isinstance(raw_paths, dict): raise ValueError("paths must be a JSON object") diff --git a/src/sailfish_devel_mcp/server.py b/src/sailfish_devel_mcp/server.py index 7982b21..cabbb77 100644 --- a/src/sailfish_devel_mcp/server.py +++ b/src/sailfish_devel_mcp/server.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +from datetime import datetime import json import sys +import time from typing import Any, TextIO from . import __version__ @@ -16,6 +18,23 @@ PROTOCOL_VERSIONS = [ "2025-03-26", "2024-11-05", ] +SERVER_NAME = "sailfish-devel-mcp" + + +def _log_value(value: Any, max_length: int = 200) -> str: + text = str(value).replace("\r", "\\r").replace("\n", "\\n") + if len(text) > max_length: + return text[: max_length - 3] + "..." + return text + + +def _log_line(message: str) -> None: + timestamp = datetime.now().astimezone().isoformat(timespec="seconds") + print(f"[{timestamp}] {SERVER_NAME} {message}", file=sys.stderr, flush=True) + + +def _duration_ms(start: float) -> int: + return int((time.monotonic() - start) * 1000) class JsonRpcError(Exception): @@ -33,26 +52,47 @@ class McpServer: def handle(self, message: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(message, dict): + _log_line("mcp request rejected reason=non_object") raise JsonRpcError(-32600, "JSON-RPC message must be an object") request_id = message.get("id") method = message.get("method") if not method: + _log_line(f"mcp request rejected id={_log_value(request_id)} reason=missing_method") raise JsonRpcError(-32600, "JSON-RPC message is missing method") if request_id is None: + _log_line(f"mcp notification method={_log_value(method)}") self._handle_notification(method) return None + started = time.monotonic() + _log_line(f"mcp request start id={_log_value(request_id)} method={_log_value(method)}") try: result = self._dispatch(method, message.get("params") or {}) + _log_line( + "mcp request finish " + f"id={_log_value(request_id)} method={_log_value(method)} " + f"status=ok duration_ms={_duration_ms(started)}" + ) return {"jsonrpc": "2.0", "id": request_id, "result": result} except JsonRpcError as exc: + _log_line( + "mcp request finish " + f"id={_log_value(request_id)} method={_log_value(method)} " + f"status=jsonrpc_error code={exc.code} duration_ms={_duration_ms(started)}" + ) error: dict[str, Any] = {"code": exc.code, "message": exc.message} if exc.data is not None: error["data"] = exc.data return {"jsonrpc": "2.0", "id": request_id, "error": error} except Exception as exc: # pragma: no cover - defensive protocol boundary + _log_line( + "mcp request finish " + f"id={_log_value(request_id)} method={_log_value(method)} " + f"status=exception exception={type(exc).__name__} " + f"duration_ms={_duration_ms(started)}" + ) return { "jsonrpc": "2.0", "id": request_id, @@ -84,9 +124,22 @@ class McpServer: def _initialize(self, params: Any) -> dict[str, Any]: requested = "" + client_name = "" + client_version = "" if isinstance(params, dict): requested = str(params.get("protocolVersion") or "") + client_info = params.get("clientInfo") + if isinstance(client_info, dict): + client_name = str(client_info.get("name") or "") + client_version = str(client_info.get("version") or "") protocol = requested if requested in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0] + _log_line( + "mcp initialize " + f"client={_log_value(client_name or 'unknown')} " + f"client_version={_log_value(client_version or 'unknown')} " + f"requested_protocol={_log_value(requested or 'unspecified')} " + f"selected_protocol={protocol}" + ) return { "protocolVersion": protocol, "capabilities": { @@ -95,7 +148,7 @@ class McpServer: "prompts": {"listChanged": False}, }, "serverInfo": { - "name": "sailfish-devel-mcp", + "name": SERVER_NAME, "version": __version__, }, "instructions": ( @@ -114,12 +167,33 @@ class McpServer: raise JsonRpcError(-32602, f"unknown tool: {name}") args = params.get("arguments") or {} if not isinstance(args, dict): + _log_line(f"mcp tool call rejected tool={_log_value(name)} reason=arguments_not_object") return tool_error("tool arguments must be an object") + arg_keys = ",".join(sorted(str(key) for key in args.keys())) or "-" + started = time.monotonic() + _log_line(f"mcp tool call start tool={_log_value(name)} arg_keys={_log_value(arg_keys)}") try: - return self.registry[name].handler(args) + result = self.registry[name].handler(args) + is_error = bool(result.get("isError")) if isinstance(result, dict) else False + _log_line( + "mcp tool call finish " + f"tool={_log_value(name)} is_error={str(is_error).lower()} " + f"duration_ms={_duration_ms(started)}" + ) + return result except ValueError as exc: + _log_line( + "mcp tool call finish " + f"tool={_log_value(name)} is_error=true exception=ValueError " + f"duration_ms={_duration_ms(started)}" + ) return tool_error(str(exc)) except Exception as exc: + _log_line( + "mcp tool call finish " + f"tool={_log_value(name)} is_error=true exception={type(exc).__name__} " + f"duration_ms={_duration_ms(started)}" + ) return tool_error( f"{name} failed: {exc}", { @@ -216,6 +290,7 @@ def main(argv: list[str] | None = None) -> None: print(json.dumps(config.public_dict(), indent=2, sort_keys=True)) return + _log_line(f"mcp server ready name={SERVER_NAME} version={__version__}") run_stdio(McpServer(config)) diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py index 854cd00..979e699 100644 --- a/src/sailfish_devel_mcp/tools.py +++ b/src/sailfish_devel_mcp/tools.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from dataclasses import dataclass from datetime import datetime, timezone import json @@ -14,7 +15,7 @@ import time from typing import Any, Callable from urllib.parse import quote -from .config import Config, DeviceConfig +from .config import AndroidBuildHostConfig, Config, DeviceConfig from .runner import ( CommandResult, remote_command, @@ -75,6 +76,18 @@ def build_registry(config: Config) -> dict[str, Tool]: Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)), Tool(_spec_build_status(), lambda args: handle_build_status(config, args)), Tool( + _spec_android_build_hosts(), + lambda args: handle_android_build_hosts(config, args), + ), + Tool( + _spec_android_build(), + lambda args: handle_android_build(config, args), + ), + Tool( + _spec_android_build_status(), + lambda args: handle_android_build_status(config, args), + ), + Tool( _spec_sdk_refresh_metadata(), lambda args: handle_sdk_refresh_metadata(config, args), ), @@ -364,29 +377,79 @@ def handle_device_user_session_command(config: Config, args: dict[str, Any]) -> def handle_device_install_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: device = _device(config, args) - rpm_path = _safe_input_path(config, _str_arg(args, "rpm_path"), allow_tmp=True) - remote_path = _optional_str(args, "remote_path") or f"/tmp/{rpm_path.name}" + rpm_path_arg = _optional_str(args, "rpm_path") + rpm_paths_arg = args.get("rpm_paths") + if rpm_path_arg and rpm_paths_arg is not None: + return tool_error("use either rpm_path or rpm_paths, not both") + if rpm_paths_arg is not None: + rpm_path_values = _string_list_arg(args, "rpm_paths") + if not rpm_path_values: + return tool_error("rpm_paths must contain at least one RPM") + elif rpm_path_arg: + rpm_path_values = [rpm_path_arg] + else: + return tool_error("rpm_path or rpm_paths is required") + + rpm_paths = [ + _safe_input_path(config, value, allow_tmp=True) + for value in rpm_path_values + ] + remote_path = _optional_str(args, "remote_path") + remote_dir = _optional_str(args, "remote_dir") + if len(rpm_paths) > 1 and remote_path: + return tool_error("remote_path only supports a single RPM; use remote_dir with rpm_paths") + if len(rpm_paths) > 1 and len({path.name for path in rpm_paths}) != len(rpm_paths): + return tool_error("rpm_paths must not contain duplicate file names") + installer = _enum_arg(args, "installer", ["pkcon", "rpm"], default="pkcon") timeout = _int_arg(args, "timeout", default=180, minimum=1, maximum=1200) - copy_result = run( - scp_to_argv(device, config.paths.ssh_config, rpm_path, remote_path), - timeout=timeout, - ) - if not copy_result.ok: - return command_result("copy RPM to device", copy_result) + mkdir_result: CommandResult | None = None + if len(rpm_paths) == 1 and not remote_dir: + remote_paths = [remote_path or f"/tmp/{rpm_paths[0].name}"] + else: + remote_dir = _remote_absolute_path(remote_dir or _new_remote_rpm_dir(), "remote_dir") + mkdir_result = _run_ssh(config, device, ["mkdir", "-p", remote_dir], timeout=timeout) + if not mkdir_result.ok: + return command_result("prepare RPM directory", mkdir_result, {"remote_dir": remote_dir}) + remote_paths = [ + str(PurePosixPath(remote_dir) / rpm_path.name) + for rpm_path in rpm_paths + ] + + copy_results = [] + for rpm_path, target_path in zip(rpm_paths, remote_paths): + copy_result = run( + scp_to_argv(device, config.paths.ssh_config, rpm_path, target_path), + timeout=timeout, + ) + copy_results.append(copy_result) + if not copy_result.ok: + return command_result( + "copy RPM to device", + copy_result, + { + "copies": [result.public_dict() for result in copy_results], + "remote_paths": remote_paths, + }, + ) if installer == "pkcon": - remote = ["pkcon", "install-local", "-y", remote_path] + remote = ["pkcon", "install-local", "-y", *remote_paths] else: - remote = ["rpm", "-Uvh", "--replacepkgs", remote_path] + remote = ["rpm", "-Uvh", "--replacepkgs", *remote_paths] install_result = _run_ssh(config, device, remote, timeout=timeout) structured = { - "copy": copy_result.public_dict(), + "copy": copy_results[0].public_dict(), + "copies": [result.public_dict() for result in copy_results], "install": install_result.public_dict(), - "remote_path": remote_path, + "remote_path": remote_paths[0], + "remote_paths": remote_paths, + "remote_dir": remote_dir, "installer": installer, } + if mkdir_result is not None: + structured["mkdir"] = mkdir_result.public_dict() return command_result("install RPM", install_result, structured) @@ -523,6 +586,123 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: } +def handle_android_build_hosts(config: Config, args: dict[str, Any]) -> dict[str, Any]: + text = "\n".join( + ( + f"{name}{' (default)' if name == config.default_android_build_host else ''}: " + f"{host.ssh_target} project={host.project_dir} state={host.state_dir}" + ) + for name, host in sorted(config.android_build_hosts.items(), key=lambda item: item[0]) + ) + structured = { + "default_android_build_host": config.default_android_build_host, + "android_build_hosts": { + name: host.public_dict() + for name, host in config.android_build_hosts.items() + }, + } + return ok_text(text or "No configured Android build hosts", structured) + + +def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + project_dir_arg = _optional_str(args, "project_dir") or host.project_dir + if not project_dir_arg: + return tool_error( + "project_dir is required; configure android_build_hosts.<host>.project_dir " + "or pass project_dir" + ) + project_dir = _remote_absolute_path( + project_dir_arg, + "project_dir", + ) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + shell_command = _str_arg(args, "shell_command") + shell = _enum_arg(args, "shell", ["bash", "sh"], default="bash") + timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600) + job_id = _optional_str(args, "job_id") or _new_android_build_job_id() + _validate_remote_job_id(job_id) + + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + remote = _android_build_start_command( + host=host, + project_dir=project_dir, + state_dir=state_dir, + job_id=job_id, + shell=shell, + shell_command=shell_command, + ) + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + structured = { + "host": host.public_dict(), + "project_dir": project_dir, + "state_dir": state_dir, + "job_id": job_id, + "job_dir": job_dir, + "log_path": log_path, + "shell": shell, + } + if not result.ok: + return command_result("start Android build", result, structured) + + text = "\n".join( + [ + f"started Android build job {job_id} on {host.name}", + f"host: {host.ssh_target}", + f"project: {project_dir}", + f"state: {job_dir}", + f"log: {log_path}", + "poll with sailfish_android_build_status", + ] + ) + if result.stdout.strip(): + text += "\n\n" + result.stdout.strip() + data = result.public_dict() + data.update(structured) + return { + "content": [{"type": "text", "text": text}], + "structuredContent": data, + "isError": False, + } + + +def handle_android_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + job_id = _optional_str(args, "job_id") + lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600) + + if job_id: + _validate_remote_job_id(job_id) + remote = _android_build_status_command(state_dir, job_id, lines) + structured: dict[str, Any] = { + "host": host.public_dict(), + "state_dir": state_dir, + "job_id": job_id, + } + else: + remote = _android_build_list_command(state_dir) + structured = {"host": host.public_dict(), "state_dir": state_dir} + + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + if job_id: + structured.update(_parse_android_build_status(result.stdout)) + response = command_result("Android build status", result, structured) + returncode = response["structuredContent"].get("returncode") + state = response["structuredContent"].get("state") + if result.ok and state == "finished" and returncode not in (0, None): + response["isError"] = True + return response + + def _mcp_state_dir() -> Path: value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR") if value: @@ -859,6 +1039,243 @@ def _run_ssh( ) +def _android_build_host(config: Config, args: dict[str, Any]) -> AndroidBuildHostConfig: + return config.android_build_host(_optional_str(args, "host")) + + +def _android_build_ssh_argv( + config: Config, + host: AndroidBuildHostConfig, + remote: str, +) -> list[str]: + argv = ["ssh"] + if config.paths.ssh_config: + argv += ["-F", str(config.paths.ssh_config)] + argv += [host.ssh_target, remote] + return argv + + +def _new_android_build_job_id() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + return f"android-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + + +def _validate_remote_job_id(job_id: str) -> None: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", job_id): + raise ValueError("job_id contains unsupported characters") + + +def _remote_absolute_path(value: str, name: str) -> str: + if "\0" in value or "\n" in value: + raise ValueError(f"{name} contains unsupported characters") + if not value.startswith("/"): + raise ValueError(f"{name} must be an absolute remote path") + return value + + +def _new_remote_rpm_dir() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + return f"/tmp/sailfish-devel-mcp-rpms-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + + +def _android_build_start_command( + *, + host: AndroidBuildHostConfig, + project_dir: str, + state_dir: str, + job_id: str, + shell: str, + shell_command: str, +) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + run_path = str(PurePosixPath(job_dir) / "run.sh") + command_path = str(PurePosixPath(job_dir) / "command") + pid_path = str(PurePosixPath(job_dir) / "pid") + created_at_path = str(PurePosixPath(job_dir) / "created_at") + started_at_path = str(PurePosixPath(job_dir) / "started_at") + finished_at_path = str(PurePosixPath(job_dir) / "finished_at") + returncode_path = str(PurePosixPath(job_dir) / "returncode") + run_script = f"""#!/bin/sh +set +e +project_dir={shlex.quote(project_dir)} +log_path={shlex.quote(log_path)} +started_at_path={shlex.quote(started_at_path)} +finished_at_path={shlex.quote(finished_at_path)} +returncode_path={shlex.quote(returncode_path)} +host_name={shlex.quote(host.name)} +shell_bin={shlex.quote(shell)} +shell_command={shlex.quote(shell_command)} + +date -Is > "$started_at_path" +printf '[%s] starting Android build on %s\\n' "$(date -Is)" "$host_name" >> "$log_path" +printf '[%s] project_dir=%s\\n' "$(date -Is)" "$project_dir" >> "$log_path" +printf '[%s] command=%s\\n' "$(date -Is)" "$shell_command" >> "$log_path" + +cd "$project_dir" +cd_rc=$? +if [ "$cd_rc" -ne 0 ]; then + printf '[%s] failed to cd to %s\\n' "$(date -Is)" "$project_dir" >> "$log_path" + printf '%s\\n' "$cd_rc" > "$returncode_path" + date -Is > "$finished_at_path" + exit "$cd_rc" +fi + +"$shell_bin" -lc "$shell_command" >> "$log_path" 2>&1 +rc=$? +printf '%s\\n' "$rc" > "$returncode_path" +date -Is > "$finished_at_path" +printf '[%s] finished returncode=%s\\n' "$(date -Is)" "$rc" >> "$log_path" +exit "$rc" +""" + encoded_run_script = base64.b64encode(run_script.encode("utf-8")).decode("ascii") + script = f""" +set -eu +state_dir={shlex.quote(state_dir)} +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +run_path={shlex.quote(run_path)} +log_path={shlex.quote(log_path)} +command_path={shlex.quote(command_path)} +pid_path={shlex.quote(pid_path)} +created_at_path={shlex.quote(created_at_path)} +mkdir -p "$state_dir" "$job_dir" +if [ -e "$pid_path" ] || [ -e {shlex.quote(returncode_path)} ]; then + echo "job already exists: $job_id" >&2 + exit 2 +fi +umask 077 +printf '%s' {shlex.quote(encoded_run_script)} | base64 -d > "$run_path" +chmod +x "$run_path" +: > "$log_path" +printf '%s\\n' {shlex.quote(shell_command)} > "$command_path" +date -Is > "$created_at_path" +nohup "$run_path" >/dev/null 2>&1 </dev/null & +pid=$! +printf '%s\\n' "$pid" > "$pid_path" +printf 'job_id: %s\\n' "$job_id" +printf 'pid: %s\\n' "$pid" +printf 'host: %s\\n' {shlex.quote(host.name)} +printf 'project_dir: %s\\n' {shlex.quote(project_dir)} +printf 'job_dir: %s\\n' "$job_dir" +printf 'log_path: %s\\n' "$log_path" +""" + return remote_command(["sh", "-lc", script]) + + +def _android_build_list_command(state_dir: str) -> str: + script = f""" +set -eu +state_dir={shlex.quote(state_dir)} +if [ ! -d "$state_dir" ]; then + echo "no android build jobs found" + exit 0 +fi +output=$( + for job_dir in "$state_dir"/*; do + [ -d "$job_dir" ] || continue + job_id=${{job_dir##*/}} + pid=$(cat "$job_dir/pid" 2>/dev/null || true) + returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) + created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) + started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) + timestamp=${{started_at:-$created_at}} + if [ -n "$returncode" ]; then + state=finished + elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + state=running + elif [ -n "$pid" ]; then + state=unknown + else + state=starting + fi + printf '%s\\t%s: %s returncode=%s pid=%s\\n' "$timestamp" "$job_id" "$state" "$returncode" "$pid" + done | sort | tail -n 20 | cut -f2- +) +if [ -n "$output" ]; then + printf '%s\\n' "$output" +else + echo "no android build jobs found" +fi +""" + return remote_command(["sh", "-lc", script]) + + +def _android_build_status_command(state_dir: str, job_id: str, lines: int) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + log_path = str(PurePosixPath(job_dir) / "build.log") + script = f""" +set -eu +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +log_path={shlex.quote(log_path)} +lines={lines} +if [ ! -d "$job_dir" ]; then + echo "unknown android build job: $job_id" >&2 + exit 2 +fi +pid=$(cat "$job_dir/pid" 2>/dev/null || true) +returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) +created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) +started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) +finished_at=$(cat "$job_dir/finished_at" 2>/dev/null || true) +command=$(cat "$job_dir/command" 2>/dev/null || true) +if [ -n "$returncode" ]; then + state=finished +elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + state=running +elif [ -n "$pid" ]; then + state=unknown +else + state=starting +fi +printf 'job_id: %s\\n' "$job_id" +printf 'state: %s\\n' "$state" +printf 'returncode: %s\\n' "$returncode" +printf 'pid: %s\\n' "$pid" +printf 'created_at: %s\\n' "$created_at" +printf 'started_at: %s\\n' "$started_at" +printf 'finished_at: %s\\n' "$finished_at" +printf 'job_dir: %s\\n' "$job_dir" +printf 'log_path: %s\\n' "$log_path" +printf 'command: %s\\n' "$command" +if [ "$lines" -gt 0 ] && [ -f "$log_path" ]; then + printf '\\n' + tail -n "$lines" "$log_path" +fi +""" + return remote_command(["sh", "-lc", script]) + + +def _parse_android_build_status(stdout: str) -> dict[str, Any]: + fields: dict[str, Any] = {} + for line in stdout.splitlines(): + if ": " not in line: + continue + key, value = line.split(": ", 1) + if key not in { + "job_id", + "state", + "returncode", + "pid", + "created_at", + "started_at", + "finished_at", + "job_dir", + "log_path", + "command", + }: + continue + if key in {"returncode", "pid"} and value: + try: + fields[key] = int(value) + except ValueError: + fields[key] = value + else: + fields[key] = value or None + return fields + + def _default_screenshot_path(device: DeviceConfig, label: str) -> str: timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") return f"{_device_home_path(device)}/Pictures/Screenshots/{label}-{timestamp}.png" @@ -1752,16 +2169,30 @@ def _spec_device_install_rpm() -> dict[str, Any]: return { "name": "sailfish_device_install_rpm", "title": "Install Device RPM", - "description": "Copy a local RPM to a Sailfish OS device and install it.", + "description": "Copy one or more local RPMs to a Sailfish OS device and install them.", "inputSchema": _object_schema( { "device": _device_prop(), - "rpm_path": {"type": "string"}, - "remote_path": {"type": "string"}, + "rpm_path": { + "type": "string", + "description": "Single local RPM path. Use rpm_paths for dependency sets.", + }, + "rpm_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Local RPM paths to copy and install in one transaction.", + }, + "remote_path": { + "type": "string", + "description": "Remote file path override for a single rpm_path.", + }, + "remote_dir": { + "type": "string", + "description": "Remote directory for copied RPMs. Created when missing.", + }, "installer": {"type": "string", "enum": ["pkcon", "rpm"], "default": "pkcon"}, "timeout": _timeout_prop(180), - }, - ["rpm_path"], + } ), "annotations": _mutating_annotations("Install Device RPM"), } @@ -1871,6 +2302,98 @@ def _spec_build_status() -> dict[str, Any]: } +def _spec_android_build_hosts() -> dict[str, Any]: + return { + "name": "sailfish_android_build_hosts", + "title": "Android Build Hosts", + "description": "List configured remote Android/AppSupport build hosts.", + "inputSchema": _object_schema({}), + "annotations": _read_only_annotations("Android Build Hosts"), + } + + +def _spec_android_build() -> dict[str, Any]: + return { + "name": "sailfish_android_build", + "title": "Start Android Build", + "description": ( + "Start a remote Android/AppSupport build under nohup on the configured " + "build host; project_dir must be configured for the host or supplied." + ), + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "project_dir": { + "type": "string", + "description": "Remote Android tree. Defaults to the host config.", + }, + "state_dir": { + "type": "string", + "description": "Remote directory where job state and logs are stored.", + }, + "shell_command": { + "type": "string", + "description": ( + "Build command run from project_dir, for example " + "'source build/envsetup.sh && lunch ... && m ...'." + ), + }, + "job_id": { + "type": "string", + "description": "Optional stable job id; autogenerated when omitted.", + }, + "shell": { + "type": "string", + "enum": ["bash", "sh"], + "default": "bash", + }, + "timeout": _timeout_prop(60), + }, + ["shell_command"], + ), + "annotations": _mutating_annotations("Start Android Build"), + } + + +def _spec_android_build_status() -> dict[str, Any]: + return { + "name": "sailfish_android_build_status", + "title": "Android Build Status", + "description": ( + "List remote Android/AppSupport build jobs or read one job's status and " + "recent log output." + ), + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "job_id": { + "type": "string", + "description": "Job id returned by sailfish_android_build. Omit to list jobs.", + }, + "state_dir": { + "type": "string", + "description": "Remote directory where job state and logs are stored.", + }, + "lines": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 80, + "description": "Number of trailing log lines to include.", + }, + "timeout": _timeout_prop(30), + } + ), + "annotations": _read_only_annotations("Android Build Status"), + } + + def _spec_sdk_refresh_metadata() -> dict[str, Any]: return { "name": "sailfish_sdk_refresh_metadata", diff --git a/tests/test_server.py b/tests/test_server.py index c7f93d5..3a80e80 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -10,8 +10,11 @@ import unittest from unittest.mock import patch from sailfish_devel_mcp.config import ( + AndroidBuildHostConfig, BUNDLED_BUILD_HELPER, Config, + DEFAULT_ANDROID_BUILD_PROJECT_DIR, + DEFAULT_ANDROID_BUILD_SSH_TARGET, DeviceConfig, PathConfig, load_config, @@ -69,6 +72,9 @@ class McpServerTests(unittest.TestCase): self.assertIn("sailfish_device_user_session_command", names) self.assertIn("sailfish_device_browser_launch", names) self.assertIn("sailfish_sdk_refresh_metadata", names) + self.assertIn("sailfish_android_build_hosts", names) + self.assertIn("sailfish_android_build", names) + self.assertIn("sailfish_android_build_status", names) self.assertIn("sailfish_qml_check_translator_ternaries", names) def test_qml_ternary_checker_reports_inline_ternary_qstrid(self) -> None: @@ -235,6 +241,15 @@ class McpServerTests(unittest.TestCase): self.assertIsNone(config.paths.local_sdk) self.assertTrue(config.paths.build_sailfishos.exists()) self.assertNotIn("build-sailfishos-skill", str(config.paths.build_sailfishos)) + self.assertEqual(config.default_android_build_host, "android-builder") + self.assertEqual( + config.android_build_hosts["android-builder"].ssh_target, + DEFAULT_ANDROID_BUILD_SSH_TARGET, + ) + self.assertEqual( + config.android_build_hosts["android-builder"].project_dir, + DEFAULT_ANDROID_BUILD_PROJECT_DIR, + ) def test_config_loads_local_sdk_from_paths(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -459,6 +474,87 @@ class McpServerTests(unittest.TestCase): self.assertIn("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/100000/dbus/user_bus_socket", remote) self.assertIn("systemctl --user status app.service", remote) + def test_device_install_rpm_keeps_single_remote_path_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rpm = root / "sample.rpm" + rpm.write_text("rpm", encoding="utf-8") + server = self.make_server(root) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.side_effect = [ + CommandResult(("scp",), 0, "", ""), + CommandResult(("ssh",), 0, "installed\n", ""), + ] + response = server.handle( + { + "jsonrpc": "2.0", + "id": 18, + "method": "tools/call", + "params": { + "name": "sailfish_device_install_rpm", + "arguments": { + "rpm_path": str(rpm), + "remote_path": "/tmp/custom.rpm", + }, + }, + } + ) + + self.assertFalse(response["result"].get("isError", False)) + calls = [list(call.args[0]) for call in mocked_run.call_args_list] + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0][-1], "root@test:/tmp/custom.rpm") + self.assertEqual(calls[1][-1], "pkcon install-local -y /tmp/custom.rpm") + self.assertEqual( + response["result"]["structuredContent"]["remote_paths"], + ["/tmp/custom.rpm"], + ) + + def test_device_install_rpm_installs_multiple_rpms_together(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rpm_a = root / "sample.rpm" + rpm_b = root / "sample-deps.rpm" + rpm_a.write_text("rpm", encoding="utf-8") + rpm_b.write_text("rpm", encoding="utf-8") + server = self.make_server(root) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.side_effect = [ + CommandResult(("ssh",), 0, "", ""), + CommandResult(("scp",), 0, "", ""), + CommandResult(("scp",), 0, "", ""), + CommandResult(("ssh",), 0, "installed\n", ""), + ] + response = server.handle( + { + "jsonrpc": "2.0", + "id": 19, + "method": "tools/call", + "params": { + "name": "sailfish_device_install_rpm", + "arguments": { + "rpm_paths": [str(rpm_a), str(rpm_b)], + "remote_dir": "/tmp/test-rpms", + "installer": "rpm", + }, + }, + } + ) + + self.assertFalse(response["result"].get("isError", False)) + calls = [list(call.args[0]) for call in mocked_run.call_args_list] + self.assertEqual(calls[0][-1], "mkdir -p /tmp/test-rpms") + self.assertEqual(calls[1][-1], "root@test:/tmp/test-rpms/sample.rpm") + self.assertEqual(calls[2][-1], "root@test:/tmp/test-rpms/sample-deps.rpm") + self.assertEqual( + calls[3][-1], + "rpm -Uvh --replacepkgs /tmp/test-rpms/sample.rpm /tmp/test-rpms/sample-deps.rpm", + ) + self.assertEqual( + response["result"]["structuredContent"]["remote_paths"], + ["/tmp/test-rpms/sample.rpm", "/tmp/test-rpms/sample-deps.rpm"], + ) + def test_browser_launch_uses_display_env_and_reports_topmost_pid(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) @@ -713,6 +809,131 @@ class McpServerTests(unittest.TestCase): self.assertIn("--arch", argv) self.assertIn("aarch64", argv) + def test_android_build_starts_remote_nohup_job(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = Config( + path=None, + default_device="phone", + devices={"phone": DeviceConfig(name="phone", ssh_target="root@phone")}, + paths=PathConfig(git_root=root, ssh_config=root / "ssh_config"), + default_android_build_host="android-builder", + android_build_hosts={ + "android-builder": AndroidBuildHostConfig( + name="android-builder", + ssh_target="builder@example.invalid", + project_dir="/remote/a15", + state_dir="/remote/state/android-builds", + ) + }, + ) + server = McpServer(config) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.return_value = CommandResult( + ("ssh",), + 0, + "job_id: android-test\npid: 1234\n", + "", + ) + response = server.handle( + { + "jsonrpc": "2.0", + "id": 16, + "method": "tools/call", + "params": { + "name": "sailfish_android_build", + "arguments": { + "job_id": "android-test", + "shell_command": "source build/envsetup.sh && m services", + }, + }, + } + ) + + self.assertFalse(response["result"].get("isError", False)) + self.assertEqual( + response["result"]["structuredContent"]["job_id"], + "android-test", + ) + argv = list(mocked_run.call_args.args[0]) + self.assertEqual(argv[:3], ["ssh", "-F", str(root / "ssh_config")]) + self.assertIn("builder@example.invalid", argv) + remote = argv[-1] + self.assertIn("nohup", remote) + self.assertIn("/remote/a15", remote) + self.assertIn("/remote/state/android-builds/android-test", remote) + self.assertIn("base64 -d", remote) + + def test_android_build_requires_configured_project_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + server = self.make_server(Path(tmp)) + response = server.handle( + { + "jsonrpc": "2.0", + "id": 16, + "method": "tools/call", + "params": { + "name": "sailfish_android_build", + "arguments": { + "job_id": "android-test", + "shell_command": "source build/envsetup.sh && m services", + }, + }, + } + ) + + self.assertTrue(response["result"].get("isError", False)) + self.assertIn( + "project_dir is required", + response["result"]["content"][0]["text"], + ) + + def test_android_build_status_tails_remote_job_log(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + server = self.make_server(root) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.return_value = CommandResult( + ("ssh",), + 0, + "\n".join( + [ + "job_id: android-test", + "state: finished", + "returncode: 7", + "pid: 1234", + "log_path: /remote/state/android-test/build.log", + "", + "build failed", + ] + ), + "", + ) + response = server.handle( + { + "jsonrpc": "2.0", + "id": 17, + "method": "tools/call", + "params": { + "name": "sailfish_android_build_status", + "arguments": { + "job_id": "android-test", + "state_dir": "/remote/state", + "lines": 25, + }, + }, + } + ) + + self.assertTrue(response["result"].get("isError", False)) + self.assertEqual(response["result"]["structuredContent"]["returncode"], 7) + argv = list(mocked_run.call_args.args[0]) + self.assertEqual(argv[:3], ["ssh", "-F", str(root / "ssh_config")]) + self.assertIn("builder@example.invalid", argv) + remote = argv[-1] + self.assertIn("tail -n \"$lines\" \"$log_path\"", remote) + self.assertIn("lines=25", remote) + def write_fake_target( self, root: Path, |
