summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Branson <andrew.branson@jolla.com>2026-06-29 16:40:30 +0200
committerAndrew Branson <andrew.branson@jolla.com>2026-06-29 17:50:20 +0200
commiteb92561bd685961889cc553cd96db4a569eefec5 (patch)
treea4df385847e712d1dfb428f28d23df58b9ed298d
parent21dcc7e6413cf1e93a37cce2098a86c2a3ab5e5a (diff)
Add remote Android build tools and request logging
Add Android build host config and MCP tools for remote AppSupport builds. Extend device RPM installs to copy dependency sets and install them together. Log MCP request and tool-call lifecycle events, and document the config.
-rw-r--r--README.md30
-rwxr-xr-xbin/sailfish-devel-mcp5
-rw-r--r--examples/config.json8
-rw-r--r--src/sailfish_devel_mcp/config.py97
-rw-r--r--src/sailfish_devel_mcp/server.py79
-rw-r--r--src/sailfish_devel_mcp/tools.py559
-rw-r--r--tests/test_server.py221
7 files changed, 977 insertions, 22 deletions
diff --git a/README.md b/README.md
index 23257e1..a2e01f7 100644
--- a/README.md
+++ b/README.md
@@ -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,