From 0075a1ad1632003a2d6ddb1354c3499f958837eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:42:41 +0900 Subject: [PATCH 01/11] refactor(cli): remove APK setup from npm package --- cli/index.js | 725 ++++++++++++++------------------------------------- 1 file changed, 189 insertions(+), 536 deletions(-) diff --git a/cli/index.js b/cli/index.js index 0662f9e..a6a56ad 100644 --- a/cli/index.js +++ b/cli/index.js @@ -1,604 +1,257 @@ #!/usr/bin/env node -// rish-mcp-setup -- Node/npx port of server/cmd/setup (Go). Same flow, -// same reasoning for what it does and doesn't automate: see the comment -// at the top of server/cmd/setup/main.go. This exists so `npx rish-mcp-setup` -// works without anyone needing Go installed. -// -// Same "no TUI framework" choice as the Go version, for the same reason: -// this has to behave correctly when piped/tested non-interactively, which -// is how it's actually verified here (no real terminal in this environment). import { createInterface } from "node:readline/promises"; -import { emitKeypressEvents } from "node:readline"; import { stdin, stdout, exit, platform } from "node:process"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, chmodSync, rmSync, readdirSync, readFileSync } from "node:fs"; -import { copyFile as fsCopyFile } from "node:fs/promises"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { randomBytes } from "node:crypto"; -import { downloadFile } from "./download.js"; -import { parseArgs, resolveServerURL } from "./args.js"; - -// --- styling --- - -const RESET = "\x1b[0m", BOLD = "\x1b[1m", DIM = "\x1b[2m"; -const ACCENT = "\x1b[38;5;209m", GOOD = "\x1b[32m", BAD = "\x1b[31m"; +import { parseArgs } from "./args.js"; +const RELAY_IMAGE = "ghcr.io/turin-dev/rish-mcp-relay:latest"; +const RELAY_CONTAINER = "rish-mcp-relay"; +const DEFAULT_PORT = 8080; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const ACCENT = "\x1b[38;5;209m"; +const GOOD = "\x1b[32m"; +const BAD = "\x1b[31m"; const useColor = !process.env.NO_COLOR && stdout.isTTY; -const style = (s, code) => (useColor ? code + s + RESET : s); -const heading = (s) => style(s, BOLD + ACCENT); -const dim = (s) => style(s, DIM); -const good = (s) => style("✓ " + s, GOOD); -const bad = (s) => style("✗ " + s, BAD); -const TOTAL_STEPS = 6; -function step(n, title) { - console.log(); - console.log(style(`[${n}/${TOTAL_STEPS}]`, BOLD + ACCENT) + " " + heading(title)); - console.log(style("─".repeat(4 + String(title).length), DIM)); -} -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -// --- non-interactive mode (--yes / -y / RISH_MCP_YES=1) --- -// For an agent bootstrapping a fresh machine: no arrow-key menu, no -// blocking prompts. --action picks the menu entry (default "setup"); -// every yes/no or default-value prompt takes its default instead of -// waiting on stdin; free-text prompts (tokens, URLs) fall back to "" , -// which every call site already treats as "skip this optional bit". -const { cliArgs, help, version, nonInteractive, argValue } = parseArgs(process.argv.slice(2), process.env); +const style = (value, code) => (useColor ? code + value + RESET : value); +const heading = (value) => style(value, BOLD + ACCENT); +const dim = (value) => style(value, DIM); +const good = (value) => style("✓ " + value, GOOD); +const bad = (value) => style("✗ " + value, BAD); + +const { help, version, nonInteractive, argValue } = parseArgs(process.argv.slice(2), process.env); const packageVersion = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version; -if (help || version) { - if (version) console.log(packageVersion); - if (help) { - console.log(`rish-mcp-setup ${packageVersion}\n\nUsage:\n npx rish-mcp-setup [options]\n\nOptions:\n --action Run one action in non-interactive mode\n --server Download from an explicitly trusted version server\n --yes, -y Accept defaults and exit after one action\n --help, -h Show this help\n --version, -v Show the package version\n\nEnvironment:\n RISH_MCP_SERVER Version-server URL; unset builds the APK locally\n RISH_MCP_YES=1 Enable non-interactive mode\n\nRequires Node.js >= 18.`); - } - exit(0); +function printHelp() { + console.log(`rish-mcp-setup ${packageVersion} + +Install the rish-mcp relay server or create an MCP client configuration. +Android APK download/build/install is intentionally not part of the npm package. + +Usage: + npx rish-mcp-setup + npx rish-mcp-setup --action server + npx rish-mcp-setup --action client + +Options: + --action Run one action + --url MCP endpoint for client config + --token AI bearer token for client config + --ai-token AI token used by the relay server + --device-token Android device token used by the relay server + --port Host port for the relay (default: 8080) + --yes, -y Accept defaults / run non-interactively + --help, -h Show this help + --version, -v Show the package version + +Environment: + RISH_MCP_URL Default MCP endpoint for client config + AI_TOKEN Relay/client AI bearer token + DEVICE_TOKEN Relay Android-device token + RISH_MCP_RELAY_PORT Relay host port + RISH_MCP_YES=1 Enable non-interactive mode + +Requires Node.js >= 18. Server install also requires Docker.`); } -// Box width is computed off the raw (unstyled) text so ANSI escape codes -// never leak into the padding math -- row() takes the raw text separately -// from the styled version that actually gets printed. -function banner(title, subtitle) { - const width = Math.max(title.length, subtitle.length) + 4; - const top = style("┌" + "─".repeat(width) + "┐", ACCENT); - const bottom = style("└" + "─".repeat(width) + "┘", ACCENT); - function row(rawText, styledText) { - const padding = " ".repeat(width - 2 - rawText.length); - return style("│ ", ACCENT) + styledText + padding + style("│", ACCENT); - } - console.log(top); - console.log(row(title, style(title, BOLD))); - console.log(row(subtitle, dim(subtitle))); - console.log(bottom); +if (version) { + console.log(packageVersion); + if (!help) exit(0); } - -// --- prompts --- - -function exitOnClosedInput() { - console.log(); - console.log(bad("input closed")); - exit(1); +if (help) { + printHelp(); + exit(0); } const rl = createInterface({ input: stdin, output: stdout }); -let stdinClosed = false; -let intentionalClose = false; -// A pending rl.question() never settles on EOF, and with no more open -// handles left Node just exits the process on its own -- abandoning that -// pending promise, so code after `await rl.question()` never runs. Found by -// actually running this with piped input: it exited 0 with no message -// instead of reporting closed input. The 'close' event, unlike the -// question promise, reliably fires -- so the exit has to happen here. -// -// 'close' also fires on our OWN rl.close() at a clean exit (menu -> Exit), -// not just on EOF -- found by testing that path too, which was reporting -// "input closed" on a perfectly normal exit. intentionalClose distinguishes -// the two. -rl.on("close", () => { - stdinClosed = true; - // In non-interactive mode nothing ever reads stdin, so a stray EOF on it - // (e.g. an agent running this with no pty attached at all) shouldn't - // abort an otherwise-successful run -- found by testing --yes with no - // stdin: it was killing the process mid-download, nowhere near a prompt. - if (!intentionalClose && !nonInteractive) exitOnClosedInput(); -}); async function prompt(label) { - if (nonInteractive) { - // Every downstream helper (promptDefault/promptYesNo) treats "" as - // "take the default", and every bare prompt() call site already - // treats "" as "skip this optional value" -- so short-circuiting - // here alone is enough to make the whole flow non-blocking. - console.log(label + " " + dim("(non-interactive, skipped)")); - return ""; - } - if (stdinClosed) exitOnClosedInput(); - const answer = await rl.question(label + " "); - return answer.trim(); -} - -async function promptDefault(label, def) { - const v = await prompt(`${label} ${dim("[" + def + "]")}`); - return v === "" ? def : v; + if (nonInteractive) return ""; + return (await rl.question(label + " ")).trim(); } -async function promptYesNo(label, def) { - const suffix = def ? "[Y/n]" : "[y/N]"; - const v = (await prompt(`${label} ${dim(suffix)}`)).toLowerCase(); - if (v === "") return def; - return v === "y" || v === "yes"; +async function promptDefault(label, value) { + const answer = await prompt(`${label} ${dim("[" + value + "]")}`); + return answer || value; } -// Arrow-key menu. Needs a real TTY in raw mode -- falls back to the old -// numbered prompt when there isn't one (piped input, e.g. this file's own -// test runs), so both stay testable without a live terminal. -async function selectMenu(title, options) { - console.log(); - console.log(heading(title)); - - if (nonInteractive) { - const wanted = argValue("--action"); - const picked = wanted ? options.find((o) => o.value === wanted) : options[0]; - if (!picked) throw new Error(`--action ${wanted} isn't one of: ${options.map((o) => o.value).join(", ")}`); - console.log(dim(`(non-interactive) → ${picked.label}`)); - return picked.value; - } - - if (!stdin.isTTY) { - options.forEach((o, i) => console.log(` ${i + 1}) ${o.label}`)); - const choice = await promptDefault("Choice", "1"); - const picked = options[parseInt(choice, 10) - 1]; - return picked ? picked.value : null; - } - - if (stdinClosed) exitOnClosedInput(); - - return new Promise((resolve) => { - let selected = 0; - let firstDraw = true; - - function render() { - if (!firstDraw) { - stdout.write(`\x1b[${options.length}A`); // cursor up to the first option line - stdout.write("\x1b[0J"); // clear from cursor to end of screen - } - firstDraw = false; - options.forEach((o, i) => { - const line = i === selected ? style(`❯ ${o.label}`, ACCENT) : ` ${o.label}`; - console.log(line); - }); - } - - function onKeypress(_str, key) { - if (!key) return; - if (key.name === "up") { - selected = (selected - 1 + options.length) % options.length; - render(); - } else if (key.name === "down") { - selected = (selected + 1) % options.length; - render(); - } else if (key.name === "return") { - cleanup(); - resolve(options[selected].value); - } else if (key.ctrl && key.name === "c") { - cleanup(); - exit(130); - } - } - - function cleanup() { - stdin.setRawMode(false); - stdin.off("keypress", onKeypress); - } - - render(); - emitKeypressEvents(stdin); - stdin.setRawMode(true); - stdin.on("keypress", onKeypress); - }); +function run(command, args, options = {}) { + return spawnSync(command, args, { encoding: "utf8", ...options }); } -// --- adb --- - -function run(cmd, args) { - return spawnSync(cmd, args, { encoding: "utf8" }); +function which(command) { + const finder = platform === "win32" ? "where" : "which"; + const result = run(finder, [command]); + if (result.status !== 0) return null; + return result.stdout.split(/\r?\n/)[0].trim() || null; } -function which(cmd) { - const finder = platform === "win32" ? "where" : "which"; - const res = run(finder, [cmd]); - if (res.status !== 0) return null; - return res.stdout.split(/\r?\n/)[0].trim() || null; +function randomToken() { + return randomBytes(32).toString("hex"); } -function cacheDir() { - const dir = path.join(homedir(), ".rish-mcp"); +function configDir() { + const dir = path.join(homedir(), ".config", "rish-mcp"); mkdirSync(dir, { recursive: true }); return dir; } -function adbBinaryName() { - return platform === "win32" ? path.join("platform-tools", "adb.exe") : path.join("platform-tools", "adb"); -} - -function platformToolsURL() { - if (platform === "win32") return "https://dl.google.com/android/repository/platform-tools-latest-windows.zip"; - if (platform === "darwin") return "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip"; - if (platform === "linux") return "https://dl.google.com/android/repository/platform-tools-latest-linux.zip"; - throw new Error(`unsupported OS ${platform} -- install adb yourself and re-run`); -} - -async function downloadPlatformTools(dir) { - const url = platformToolsURL(); - console.log(dim("downloading " + url)); - const zipPath = path.join(dir, "platform-tools.zip"); - await downloadFile(url, zipPath); - try { - const { default: AdmZip } = await import("adm-zip"); - new AdmZip(zipPath).extractAllTo(dir, true); - } finally { - rmSync(zipPath, { force: true }); +function parsePort(raw) { + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new Error(`invalid port: ${raw}`); } - const adbPath = path.join(dir, adbBinaryName()); - if (platform !== "win32") chmodSync(adbPath, 0o755); - if (!existsSync(adbPath)) throw new Error("adb missing from extracted archive"); - return adbPath; + return value; } -async function ensureADB() { - const found = which("adb"); - if (found) return found; - const dir = cacheDir(); - const cached = path.join(dir, adbBinaryName()); - if (existsSync(cached)) return cached; - console.log(dim("adb not found on PATH.")); - if (!(await promptYesNo("Download Android platform-tools now?", true))) { - throw new Error("adb is required"); +function readEnvFile(filePath) { + if (!existsSync(filePath)) return {}; + const result = {}; + for (const line of readFileSync(filePath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index === -1) continue; + result[trimmed.slice(0, index)] = trimmed.slice(index + 1); } - return downloadPlatformTools(dir); + return result; } -function listDevices(adbPath) { - const res = run(adbPath, ["devices"]); - if (res.status !== 0) throw new Error("adb devices failed: " + res.stderr); - return res.stdout - .split(/\r?\n/) - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith("List of devices")) - .map((l) => l.split(/\s+/)) - .filter((f) => f.length === 2 && f[1] === "device") - .map((f) => f[0]); -} - -// --- APK acquisition --- - -function findRepoRoot() { - let dir = process.cwd(); - for (;;) { - if (existsSync(path.join(dir, "app", "Dockerfile.build"))) return dir; - const parent = path.dirname(dir); - if (parent === dir) { - throw new Error( - "couldn't find a rish-mcp checkout in this or any parent directory (looking for app/Dockerfile.build).\n" + - " git clone https://github.com/turin-dev/rish-mcp.git && cd rish-mcp\n" + - "then run npx rish-mcp-setup again from there." - ); +async function chooseAction() { + const requested = argValue("--action"); + if (requested) { + if (!['server', 'client'].includes(requested)) { + throw new Error(`--action must be server or client (got ${requested})`); } - dir = parent; + return requested; } -} + if (nonInteractive) return "server"; -async function ensureGoogleServicesJSON(appDir) { - const target = path.join(appDir, "app", "google-services.json"); - if (existsSync(target)) { - console.log(good("google-services.json found -- FCM wake path will be built in")); - return; - } - console.log(dim("No app/app/google-services.json -- building without the FCM low-spec wake path.")); - console.log(dim("(Regular devices work fine without it; this only affects Wear OS-style wake.)")); - if (!(await promptYesNo("Do you have your own Firebase project's google-services.json to add?", false))) return; - const src = await prompt("Path to it:"); - if (!src) return; - await fsCopyFile(src, target); - console.log(good("copied -- FCM wake path will be built in")); + console.log(); + console.log(heading("rish-mcp setup")); + console.log(dim("npm installs only the relay server or MCP client config; APK handling lives outside npm.")); + console.log(); + console.log(" 1) Install/update relay server"); + console.log(" 2) Configure MCP client"); + console.log(" 3) Exit"); + const answer = await promptDefault("Choice", "1"); + if (answer === "1") return "server"; + if (answer === "2") return "client"; + return "exit"; } -async function buildLocally() { +async function installServer() { if (!which("docker")) { - throw new Error("docker not found -- install Docker Desktop, or pass --server to download a prebuilt APK instead"); + throw new Error("Docker is required to install the relay server"); } - const repoRoot = findRepoRoot(); - const appDir = path.join(repoRoot, "app"); - await ensureGoogleServicesJSON(appDir); - - console.log(dim("docker build -t rishmcp-android-build -f Dockerfile.build " + appDir)); - const build = spawnSync("docker", ["build", "-t", "rishmcp-android-build", "-f", "Dockerfile.build", "."], { - cwd: appDir, - stdio: "inherit", - }); - if (build.status !== 0) throw new Error("docker build failed"); - - console.log(dim("running the build inside the image and copying the APK out")); - const runRes = spawnSync( - "docker", - ["run", "--rm", "-v", `${appDir}:/work`, "rishmcp-android-build", "bash", "-c", "cd /work && gradle assembleDebug --no-daemon"], - { stdio: "inherit" } - ); - if (runRes.status !== 0) throw new Error("gradle build failed"); - const outDir = path.join(appDir, "app", "build", "outputs", "apk", "debug"); - if (!existsSync(outDir)) throw new Error(`no build output at ${outDir}`); - const apk = readdirSync(outDir).find((f) => f.endsWith(".apk")); - if (!apk) throw new Error(`no .apk found in ${outDir}`); - return path.join(outDir, apk); -} - -async function acquireAPK(serverURL) { - const dir = cacheDir(); - if (!serverURL) { - console.log(dim("No --server / RISH_MCP_SERVER set -- building locally instead.")); - return buildLocally(); - } - console.log("A version server is configured: " + serverURL); - if (!(await promptYesNo("Download the official prebuilt APK from it?", true))) { - return buildLocally(); - } - const apkURL = serverURL.replace(/\/+$/, "") + "/agent.apk"; - const dest = path.join(dir, "rish-mcp-agent.apk"); - console.log(dim("downloading " + apkURL)); - await downloadFile(apkURL, dest); - return dest; -} + const port = parsePort( + argValue("--port") || process.env.RISH_MCP_RELAY_PORT || String(DEFAULT_PORT), + ); + const relayEnvPath = path.join(configDir(), "relay.env"); + const previous = readEnvFile(relayEnvPath); -// --- relay --- + let aiToken = argValue("--ai-token") || process.env.AI_TOKEN || previous.AI_TOKEN || ""; + let deviceToken = argValue("--device-token") || process.env.DEVICE_TOKEN || previous.DEVICE_TOKEN || ""; -function randomToken() { - return randomBytes(24).toString("hex"); -} + if (!aiToken) aiToken = randomToken(); + if (!deviceToken) deviceToken = randomToken(); -const RELAY_IMAGE = "ghcr.io/turin-dev/rish-mcp-relay:latest"; + writeFileSync( + relayEnvPath, + `AI_TOKEN=${aiToken}\nDEVICE_TOKEN=${deviceToken}\n`, + { mode: 0o600 }, + ); + if (platform !== "win32") chmodSync(relayEnvPath, 0o600); -async function runStartRelay() { - let repoRoot = null; - try { - repoRoot = findRepoRoot(); - } catch { - // No local checkout -- that's fine, we fall back to the prebuilt image - // below instead of requiring one just to run the relay. - } + console.log(dim(`Pulling ${RELAY_IMAGE}`)); + const pull = run("docker", ["pull", RELAY_IMAGE], { stdio: "inherit" }); + if (pull.status !== 0) throw new Error("docker pull failed"); - let aiToken = process.env.AI_TOKEN || ""; - if (!aiToken) { - if (await promptYesNo("No AI_TOKEN set. Generate a random one?", true)) { - aiToken = randomToken(); - console.log(dim("AI_TOKEN=" + aiToken)); - } else { - aiToken = await prompt("AI_TOKEN:"); - } - } - let deviceToken = process.env.DEVICE_TOKEN || ""; - if (!deviceToken) { - if (await promptYesNo("No DEVICE_TOKEN set. Generate a random one?", true)) { - deviceToken = randomToken(); - console.log(dim("DEVICE_TOKEN=" + deviceToken)); - } else { - deviceToken = await prompt("DEVICE_TOKEN:"); - } - } - if (!aiToken || !deviceToken) throw new Error("AI_TOKEN and DEVICE_TOKEN are both required"); - const port = await promptDefault("Port", "8080"); - - const env = { ...process.env, AI_TOKEN: aiToken, DEVICE_TOKEN: deviceToken, PORT: port }; - - if (repoRoot) { - const serverDir = path.join(repoRoot, "server"); - if (which("go")) { - console.log(dim("go run ./cmd/relay")); - console.log(good("starting relay on :" + port + " -- Ctrl+C to stop")); - const res = spawnSync("go", ["run", "./cmd/relay"], { cwd: serverDir, env, stdio: "inherit" }); - if (res.status !== 0) throw new Error("relay exited with an error"); - return; - } - if (which("docker")) { - console.log(dim("docker build --target relay -t rishmcp-relay " + serverDir)); - const build = spawnSync("docker", ["build", "--target", "relay", "-t", "rishmcp-relay", "."], { cwd: serverDir, stdio: "inherit" }); - if (build.status !== 0) throw new Error("docker build failed"); - console.log(good("starting relay on :" + port + " -- Ctrl+C to stop")); - const res = spawnSync( - "docker", - ["run", "--rm", "-p", `${port}:${port}`, "-e", "AI_TOKEN=" + aiToken, "-e", "DEVICE_TOKEN=" + deviceToken, "-e", "PORT=" + port, "rishmcp-relay"], - { stdio: "inherit" } - ); - if (res.status !== 0) throw new Error("relay exited with an error"); - return; - } - } + run("docker", ["rm", "-f", RELAY_CONTAINER], { stdio: "ignore" }); - // No local checkout (or no go/docker to build it with) -- fall back to - // the prebuilt image, which needs neither. - if (!which("docker")) { - throw new Error( - repoRoot - ? "neither go nor docker found -- install one to run the relay" - : "no local checkout and docker not found -- install Docker to run the prebuilt relay image, or clone the repo and install Go" - ); - } - if (!repoRoot) console.log(dim("No local checkout found -- pulling the prebuilt image instead.")); - console.log(dim("docker pull " + RELAY_IMAGE)); - const pull = spawnSync("docker", ["pull", RELAY_IMAGE], { stdio: "inherit" }); - if (pull.status !== 0) throw new Error("docker pull failed"); - console.log(good("starting relay on :" + port + " -- Ctrl+C to stop")); - const res = spawnSync( + console.log(dim(`Starting ${RELAY_CONTAINER} on port ${port}`)); + const start = run( "docker", - ["run", "--rm", "-p", `${port}:${port}`, "-e", "AI_TOKEN=" + aiToken, "-e", "DEVICE_TOKEN=" + deviceToken, "-e", "PORT=" + port, RELAY_IMAGE], - { stdio: "inherit" } + [ + "run", + "-d", + "--name", + RELAY_CONTAINER, + "--restart", + "unless-stopped", + "--env-file", + relayEnvPath, + "-p", + `${port}:8080`, + RELAY_IMAGE, + ], + { stdio: "inherit" }, ); - if (res.status !== 0) throw new Error("relay exited with an error"); -} - -// --- device setup (steps 1-6) --- + if (start.status !== 0) throw new Error("docker run failed"); -async function runDeviceSetup(serverURL) { - let adbPath; - try { - adbPath = await ensureADB(); - } catch (e) { - throw new Error("couldn't get adb: " + e.message); - } - console.log(good("adb ready: " + adbPath)); + console.log(); + console.log(good("relay server installed")); + console.log(`MCP URL: http://localhost:${port}/mcp`); + console.log(`AI_TOKEN: ${aiToken}`); + console.log(`DEVICE_TOKEN: ${deviceToken}`); + console.log(`Secrets: ${relayEnvPath}`); + console.log(dim("Keep both tokens secret. The Android app needs DEVICE_TOKEN; MCP clients use AI_TOKEN.")); +} - if (!serverURL && !which("docker")) { - console.log(bad("no way to get an APK: Docker isn't installed, and no --server / RISH_MCP_SERVER is set.")); - console.log(dim("Either install Docker Desktop (used to build the app without needing the Android SDK locally),")); - console.log(dim("or pass --server to download a prebuilt APK instead.")); - return; - } +async function configureClient() { + const relayEnvPath = path.join(configDir(), "relay.env"); + const localRelay = readEnvFile(relayEnvPath); - step(1, "connect your device"); - console.log("Plug the phone in over USB and enable USB debugging (Settings → Developer"); - console.log("options → USB debugging), or make sure it's already reachable over adb."); - const maxAttempts = nonInteractive ? 15 : Infinity; // ~30s of polling, not a busy-loop, in agent mode - let deviceFound = false; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (nonInteractive) { - if (attempt > 0) await sleep(2000); - } else { - await prompt(dim("press enter once it's connected")); - } - let devices; - try { - devices = listDevices(adbPath); - } catch (e) { - console.log(bad(e.message)); - continue; - } - if (devices.length === 0) { - console.log(bad("no device seen by `adb devices` yet -- check the USB debugging prompt on the phone")); - continue; - } - console.log(good(`found device: ${devices[0]}`)); - deviceFound = true; - break; - } - if (!deviceFound) { - throw new Error(`no device showed up on \`adb devices\` after ${maxAttempts} attempts (non-interactive mode doesn't wait forever)`); - } + const defaultUrl = argValue("--url") || process.env.RISH_MCP_URL || "http://localhost:8080/mcp"; + const url = nonInteractive ? defaultUrl : await promptDefault("MCP URL", defaultUrl); - step(2, "Android version"); - const is11Plus = await promptYesNo("Is the device on Android 11 or newer?", true); - let bridgePort = ""; - if (!is11Plus) { - console.log(dim("Wireless pairing doesn't exist before Android 11, so we bridge over USB instead.")); - bridgePort = await promptDefault("TCP port for the adb bridge", "5555"); - const res = run(adbPath, ["tcpip", bridgePort]); - if (res.status !== 0) { - throw new Error("adb tcpip failed: " + (res.stderr || res.stdout).trim()); - } - console.log(good(`adbd now listening on port ${bridgePort} -- you can unplug the USB cable`)); + let token = argValue("--token") || process.env.AI_TOKEN || localRelay.AI_TOKEN || ""; + if (!token && !nonInteractive) token = await prompt("AI_TOKEN:"); + if (!token) { + throw new Error("AI token is required; pass --token or set AI_TOKEN"); } - step(3, "get the APK"); - const apkPath = await acquireAPK(serverURL); - console.log(good("APK ready: " + apkPath)); - - step(4, "install"); - const install = run(adbPath, ["install", "-r", apkPath]); - const installOut = (install.stdout || "") + (install.stderr || ""); - if (install.status !== 0 || !installOut.includes("Success")) { - throw new Error("adb install failed:\n" + installOut); - } - console.log(good("installed")); - - step(5, "configure the app"); - console.log(dim("These get sent to the app as launch extras, not baked into the build --")); - console.log(dim("see docs/USAGE.md §3.3 (headless provisioning).")); - const relayURL = await prompt("Relay URL (e.g. wss://mcp.example.com/agent):"); - const deviceToken = await prompt("Device token:"); - if (relayURL && deviceToken) { - const amArgs = [ - "shell", "am", "start", "-n", "kr.scin.rishmcp/.MainActivity", - "--es", "relay", relayURL, - "--es", "token", deviceToken, - "--ez", "autostart", "true", - ]; - if (bridgePort) amArgs.push("--ei", "adbPort", bridgePort); - const res = run(adbPath, amArgs); - if (res.status !== 0) { - console.log(bad("am start failed: " + (res.stderr || res.stdout).trim())); - } else { - console.log(good("app launched with relay + token pre-filled")); - } - } else { - console.log(dim("skipped -- you can fill these in from the app's Configuration card instead")); - } + const config = { + mcpServers: { + phone: { + type: "http", + url, + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }, + }; + + const clientPath = path.join(configDir(), "client.json"); + writeFileSync(clientPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); + if (platform !== "win32") chmodSync(clientPath, 0o600); - step(6, "pair the app"); - if (is11Plus) { - console.log("On the phone: Settings → Developer options → Wireless debugging → Pair device"); - console.log('with pairing code. Enter that port + 6-digit code in the app\'s "ADB shell'); - console.log('access" card, tap Pair. Then note the (different) port on the main Wireless'); - console.log('debugging screen, enter it under "Connect port", tap Save port.'); - } else { - console.log(`The bridge port (${bridgePort}) is already listening and, if you filled in step 5,`); - console.log("already saved as the app's Connect port."); - } - if (!relayURL || !deviceToken) { - console.log("Fill in Relay URL + Device token in the Configuration card and tap Save & Start."); - } console.log(); - console.log(good("done")); -} - -// --- menu --- - -async function main() { - // Fail closed: legacy GitHub releases contain the Shizuku implementation. - // Build the rewrite locally unless the user explicitly trusts a compatible - // version server via --server or RISH_MCP_SERVER. - const serverURL = resolveServerURL(cliArgs, process.env); - - banner("rish-mcp setup", "adb + pairing + build + relay, all from one place."); - - const menuOptions = [ - { label: "📱 Full device setup — adb, pairing, install the app", value: "setup" }, - { label: "📦 Just build/download the APK", value: "apk" }, - { label: "🛰 Start a relay server", value: "relay" }, - { label: "🚪 Exit", value: "exit" }, - ]; - - for (;;) { - let choice; - try { - choice = await selectMenu("What do you want to do?", menuOptions); - if (choice === "setup") await runDeviceSetup(serverURL); - else if (choice === "apk") { - const apkPath = await acquireAPK(serverURL); - console.log(good("APK ready: " + apkPath)); - } else if (choice === "relay") await runStartRelay(); - else if (choice === "exit" || choice === null) break; - } catch (e) { - console.log(bad(e.message)); - if (nonInteractive) { - intentionalClose = true; - rl.close(); - exit(1); - } - } - - // Non-interactive mode does exactly the one --action and stops -- - // there's nobody to ask "back to the menu?", and that prompt - // short-circuiting to its default would otherwise loop forever. - if (nonInteractive) break; - if (choice !== "exit" && !(await promptYesNo("Back to the menu?", true))) break; - } - intentionalClose = true; - rl.close(); + console.log(good("MCP client configuration created")); + console.log(`Saved to: ${clientPath}`); + console.log(); + console.log(JSON.stringify(config, null, 2)); + console.log(); + console.log(dim("Copy this mcpServers entry into your MCP-compatible AI client's configuration.")); } -main().catch((error) => { +try { + const action = await chooseAction(); + if (action === "server") await installServer(); + if (action === "client") await configureClient(); +} catch (error) { + console.error(); console.error(bad(error instanceof Error ? error.message : String(error))); - intentionalClose = true; + process.exitCode = 1; +} finally { rl.close(); - exit(1); -}); +} From 7046fec6726e8429b29decde7c5d49dd341c60c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:42:48 +0900 Subject: [PATCH 02/11] refactor(cli): keep only generic setup arguments --- cli/args.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cli/args.js b/cli/args.js index 93a5bc2..bea0948 100644 --- a/cli/args.js +++ b/cli/args.js @@ -21,13 +21,3 @@ export function parseArgs(argv = [], env = {}) { return { cliArgs, help, version, nonInteractive, argValue }; } - -// APK downloads are opt-in. Releases from the legacy product generation must -// never become the default merely because a public version server exists. -export function resolveServerURL(argv = [], env = {}) { - const { cliArgs, argValue } = parseArgs(argv, env); - if (cliArgs.some((arg) => arg === "--server" || arg.startsWith("--server="))) { - return argValue("--server") ?? ""; - } - return env.RISH_MCP_SERVER || ""; -} From d5c0e8913417b721cb52319e8ff03f692b35586a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:42:54 +0900 Subject: [PATCH 03/11] chore(cli): prepare 0.9.0 server-client-only package --- cli/package.json | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cli/package.json b/cli/package.json index 862b80b..d20fa2f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,13 +1,12 @@ { "name": "rish-mcp-setup", - "version": "0.8.1", - "description": "Interactive installer for the rish-mcp Android agent -- adb, pairing, APK, install.", + "version": "0.9.0", + "description": "Install the rish-mcp relay server or create MCP client configuration.", "keywords": [ "rish-mcp", "mcp", "model-context-protocol", "android", - "adb", "cli", "installer", "setup", @@ -30,7 +29,6 @@ "files": [ "index.js", "args.js", - "download.js", "README.md" ], "engines": { @@ -38,8 +36,5 @@ }, "scripts": { "test": "node --test" - }, - "dependencies": { - "adm-zip": "^0.6.0" } } From cb667508fd0df05c72e28f4e54d8e102a201f987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:43:00 +0900 Subject: [PATCH 04/11] chore(cli): drop APK extraction dependency --- cli/package-lock.json | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e020001..831f101 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,31 +1,19 @@ { "name": "rish-mcp-setup", - "version": "0.8.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rish-mcp-setup", - "version": "0.8.1", + "version": "0.9.0", "license": "MIT", - "dependencies": { - "adm-zip": "^0.6.0" - }, "bin": { "rish-mcp-setup": "index.js" }, "engines": { "node": ">=18" } - }, - "node_modules/adm-zip": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", - "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", - "license": "MIT", - "engines": { - "node": ">=14.0" - } } } } From 9c7f49d1ff9caff53a18a8e75c0c805c8bf9e0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:43:11 +0900 Subject: [PATCH 05/11] test(cli): remove APK-specific argument cases --- cli/test/args.test.js | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/cli/test/args.test.js b/cli/test/args.test.js index 599154a..2f7c955 100644 --- a/cli/test/args.test.js +++ b/cli/test/args.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseArgs, resolveServerURL } from "../args.js"; +import { parseArgs } from "../args.js"; test("parseArgs recognizes help and version flags", () => { assert.equal(parseArgs(["--help"], {}).help, true); @@ -19,13 +19,13 @@ test("parseArgs recognizes non-interactive flags", () => { }); test("parseArgs supports equals and separate values", () => { - const equals = parseArgs(["--action=apk", "--server=https://example.test"], {}); - assert.equal(equals.argValue("--action"), "apk"); - assert.equal(equals.argValue("--server"), "https://example.test"); + const equals = parseArgs(["--action=server", "--port=8080"], {}); + assert.equal(equals.argValue("--action"), "server"); + assert.equal(equals.argValue("--port"), "8080"); - const separate = parseArgs(["--action", "relay", "--server", ""], {}); - assert.equal(separate.argValue("--action"), "relay"); - assert.equal(separate.argValue("--server"), ""); + const separate = parseArgs(["--action", "client", "--url", "https://mcp.example.test/mcp"], {}); + assert.equal(separate.argValue("--action"), "client"); + assert.equal(separate.argValue("--url"), "https://mcp.example.test/mcp"); }); test("parseArgs does not consume another flag as a value", () => { @@ -33,14 +33,3 @@ test("parseArgs does not consume another flag as a value", () => { assert.equal(parsed.argValue("--action"), undefined); assert.equal(parsed.nonInteractive, true); }); - -test("resolveServerURL fails closed unless a server is explicitly configured", () => { - assert.equal(resolveServerURL([], {}), ""); - assert.equal(resolveServerURL([], { RISH_MCP_SERVER: "https://env.example" }), "https://env.example"); - assert.equal( - resolveServerURL(["--server=https://cli.example"], { RISH_MCP_SERVER: "https://env.example" }), - "https://cli.example", - ); - assert.equal(resolveServerURL(["--server="], { RISH_MCP_SERVER: "https://env.example" }), ""); - assert.equal(resolveServerURL(["--server"], { RISH_MCP_SERVER: "https://env.example" }), ""); -}); From 7572982c9834d3b337ff4a4157374b44fe0f1a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:43:22 +0900 Subject: [PATCH 06/11] docs(cli): document server-client-only npm scope --- cli/README.md | 107 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/cli/README.md b/cli/README.md index c6b05f3..d5f31e0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,74 +1,95 @@ # rish-mcp-setup -Interactive installer for the [rish-mcp](https://github.com/turin-dev/rish-mcp) Android agent — the thing that lets an AI run shell commands on a real Android device over MCP. +`rish-mcp-setup` is the npm setup utility for the **server side** of rish-mcp and for creating an **MCP client configuration**. + +It intentionally does **not** download, build, install, or update the Android APK. Android agent releases are handled separately through the signed GitHub release channel. ```bash -git clone https://github.com/turin-dev/rish-mcp.git -cd rish-mcp -npx rish-mcp-setup --server= +npx rish-mcp-setup ``` -The empty `--server=` value forces a local APK build. This is currently -required because GitHub releases through `v0.5.0` contain the legacy -Shizuku-based application, not the no-Shizuku rewrite. No global install, Go -toolchain, or host Android SDK is required, but local APK builds require Docker. +The menu contains only: -## What it does +1. **Install/update relay server** — pulls `ghcr.io/turin-dev/rish-mcp-relay:latest`, creates persistent `AI_TOKEN` and `DEVICE_TOKEN` values when needed, stores them under `~/.config/rish-mcp/relay.env`, and starts the relay with Docker. +2. **Configure MCP client** — creates a standard `mcpServers` JSON entry pointing at the relay and stores it under `~/.config/rish-mcp/client.json`. +3. **Exit** -An arrow-key menu with four options: +## Non-interactive usage -1. **Full device setup** — makes sure `adb` is available (downloads platform-tools if it isn't), waits for a device to show up on `adb devices`, bridges pre-Android-11 devices over `adb tcpip`, gets an APK onto the device (downloaded from a configured version server, or built locally via Docker), installs it, and pre-fills the Relay URL / device token via the app's own intent-extras provisioning — nothing gets baked into the build. -2. **Just build/download the APK** — the APK-acquisition step on its own. -3. **Start a relay server** — runs `server/cmd/relay` from a local checkout if you have one (via `go run` or a local Docker build), or falls back to pulling the prebuilt `ghcr.io/turin-dev/rish-mcp-relay` image if you don't. -4. **Exit** +Install or update the relay server: -One thing it deliberately does **not** do: drive the Android 11+ wireless-pairing handshake itself. That happens on-device, inside the app — the whole point of rish-mcp not needing Shizuku or a PC in the loop. A PC's `adb` is only load-bearing for installing the APK and for the pre-Android-11 `adb tcpip` bridge; this tool covers exactly those two things, then hands off to the app's own pairing screen. +```bash +npx rish-mcp-setup --yes --action server +``` -## Options +Create an MCP client configuration: ```bash -npx rish-mcp-setup --server https://your-version-server.example.com +npx rish-mcp-setup --yes --action client \ + --url https://mcp.example.com/mcp \ + --token "$AI_TOKEN" ``` -`--server` (or the `RISH_MCP_SERVER` env var) points at an explicitly trusted, -rewrite-compatible rish-mcp version server. Without it, the safe default is to -build locally with Docker. The legacy public APK must not be used with the -current rewrite; see [`../docs/RELEASES.md`](../docs/RELEASES.md). +## Server options -### Non-interactive setup +```bash +npx rish-mcp-setup --action server \ + --port 8080 \ + --ai-token "$AI_TOKEN" \ + --device-token "$DEVICE_TOKEN" +``` -For scripts or a fresh machine where no one can answer prompts, pass `--yes` -(or `-y`) and choose the menu action with `--action`: +If tokens are omitted, the installer reuses values from `~/.config/rish-mcp/relay.env` when available and otherwise creates cryptographically random tokens. The relay is installed as the `rish-mcp-relay` Docker container with `--restart unless-stopped`. -```bash -# Full device setup; accepts defaults and uses RISH_MCP_* environment values. -npx rish-mcp-setup --yes --action setup --server= +Environment equivalents: + +- `AI_TOKEN` +- `DEVICE_TOKEN` +- `RISH_MCP_RELAY_PORT` +- `RISH_MCP_YES=1` -# Only acquire an APK. -npx rish-mcp-setup --yes --action apk --server= +Docker is required for the server action. -# Start the relay from a checkout, or use the published fallback image. -npx rish-mcp-setup --yes --action relay +## Client options + +```bash +npx rish-mcp-setup --action client \ + --url https://mcp.example.com/mcp \ + --token "$AI_TOKEN" ``` -The equivalent environment switch is `RISH_MCP_YES=1`. Use `--help` to see -all flags, or `--version` to print the installed package version: +The generated configuration looks like this: + +```json +{ + "mcpServers": { + "phone": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +If the relay was installed on the same machine, the client action can reuse the locally stored `AI_TOKEN` automatically. The generated file contains a bearer token, so it is written with owner-only permissions where supported. + +## Android agent + +The npm package has no APK code or Android tooling dependencies. Do not use it to install the Android app. + +Use the signed `agent-v*` GitHub release channel for Android artifacts and follow the release notes for pairing and device setup. + +## Other commands ```bash npx rish-mcp-setup --help npx rish-mcp-setup --version ``` -The CLI requires Node.js 18 or newer. Help and version output do not load the -ZIP extraction dependency or contact the network. Downloads have a two-minute -timeout and replace cached files only after the new file completes. Non-interactive -mode still needs the real prerequisites for the selected action: Docker or a local -APK/build for APK acquisition, and `adb` plus an authorized device for full setup. - -## Also available as a Go binary - -Same tool, same flow, ported 1:1 — see [`server/cmd/setup`](../server/cmd/setup) -if you'd rather have a single static binary than a Node dependency. +Node.js 18 or newer is required. ## License From 80911afeba7b2bd4f8f06ceb75c505baf54680ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:43:27 +0900 Subject: [PATCH 07/11] refactor(cli): remove APK download helper --- cli/download.js | 72 ------------------------------------------------- 1 file changed, 72 deletions(-) delete mode 100644 cli/download.js diff --git a/cli/download.js b/cli/download.js deleted file mode 100644 index 079e414..0000000 --- a/cli/download.js +++ /dev/null @@ -1,72 +0,0 @@ -import { createWriteStream, existsSync, rmSync, renameSync } from "node:fs"; -import { pipeline } from "node:stream/promises"; -import { Transform, Readable } from "node:stream"; - -export const DOWNLOAD_TIMEOUT_MS = 120_000; -export const DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024; // 100 MB - -// limitBytes returns a Transform that errors if more than maxBytes flow -// through it. This prevents a malicious or misconfigured server from filling -// the disk with an unbounded download. -function limitBytes(maxBytes) { - let total = 0; - return new Transform({ - transform(chunk, _encoding, callback) { - total += chunk.byteLength; - if (total > maxBytes) { - callback(new Error(`download exceeded ${maxBytes} bytes`)); - } else { - callback(null, chunk); - } - }, - }); -} - -function replaceDownloadedFile(temp, dest) { - try { - renameSync(temp, dest); - return; - } catch (error) { - // Windows refuses to rename over an existing file. Move the old cache - // aside only after the new file is fully downloaded, then restore it if - // the final rename fails. - if (process.platform !== "win32" || !existsSync(dest)) throw error; - } - - const backup = `${dest}.backup-${process.pid}-${Date.now()}`; - renameSync(dest, backup); - try { - renameSync(temp, dest); - } catch (error) { - if (!existsSync(dest)) renameSync(backup, dest); - throw error; - } - rmSync(backup, { force: true }); -} - -export async function downloadFile(url, dest, { timeoutMs = DOWNLOAD_TIMEOUT_MS, maxBytes = DOWNLOAD_MAX_BYTES } = {}) { - const temp = `${dest}.part-${process.pid}-${Date.now()}`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(url, { signal: controller.signal }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - if (!res.body) throw new Error("response has no body"); - await pipeline( - Readable.fromWeb(res.body), - limitBytes(maxBytes), - createWriteStream(temp), - ); - replaceDownloadedFile(temp, dest); - } catch (error) { - const detail = error?.name === "AbortError" - ? `timed out after ${timeoutMs / 1000}s` - : error instanceof Error - ? error.message - : String(error); - throw new Error(`download failed: ${detail}`, { cause: error }); - } finally { - clearTimeout(timeout); - rmSync(temp, { force: true }); - } -} From ffdc41d6560fc5ffb00d33f630a04b7dbd4d628f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:43:32 +0900 Subject: [PATCH 08/11] test(cli): remove APK download tests --- cli/test/download.test.js | 76 --------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 cli/test/download.test.js diff --git a/cli/test/download.test.js b/cli/test/download.test.js deleted file mode 100644 index 5c3e765..0000000 --- a/cli/test/download.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { downloadFile } from "../download.js"; - -function tempPath() { - return mkdtempSync(path.join(tmpdir(), "rish-mcp-download-")); -} - -async function startServer(handler) { - const { createServer } = await import("node:http"); - const server = createServer(handler); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - return server; -} - -test("downloadFile replaces the destination only after a complete response", async () => { - const dir = tempPath(); - const dest = path.join(dir, "agent.apk"); - writeFileSync(dest, "old-cache"); - const server = await startServer((_req, res) => { - res.writeHead(200, { "content-type": "application/octet-stream" }); - res.end("new-cache"); - }); - try { - await downloadFile(`http://127.0.0.1:${server.address().port}/agent.apk`, dest, { timeoutMs: 5_000 }); - assert.equal(readFileSync(dest, "utf8"), "new-cache"); - } finally { - server.close(); - } -}); - -test("downloadFile removes partial output and preserves an existing cache on failure", async () => { - const dir = tempPath(); - const dest = path.join(dir, "agent.apk"); - writeFileSync(dest, "old-cache"); - const server = await startServer((_req, res) => { - res.writeHead(200, { "content-type": "application/octet-stream" }); - res.write("partial"); - setTimeout(() => res.destroy(), 10); - }); - try { - await assert.rejects( - downloadFile(`http://127.0.0.1:${server.address().port}/agent.apk`, dest, { timeoutMs: 5_000 }), - /download failed/ - ); - assert.equal(readFileSync(dest, "utf8"), "old-cache"); - assert.equal(existsSync(`${dest}.part-${process.pid}`), false); - } finally { - server.close(); - } -}); - -test("downloadFile rejects responses larger than maxBytes", async () => { - const dir = tempPath(); - const dest = path.join(dir, "agent.apk"); - const server = await startServer((_req, res) => { - res.writeHead(200, { "content-type": "application/octet-stream" }); - res.end("0123456789"); - }); - try { - await assert.rejects( - downloadFile(`http://127.0.0.1:${server.address().port}/agent.apk`, dest, { maxBytes: 5 }), - /download failed: download exceeded 5 bytes/ - ); - assert.equal(existsSync(dest), false); - assert.equal(readdirSync(dir).some((f) => f.endsWith(".part-")), false); - } finally { - server.close(); - } -}); From c31a8525580dbc73bb8ff744c2757b6a0b907349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:44:01 +0900 Subject: [PATCH 09/11] docs: separate npm setup from Android agent installation --- README.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0615a22..01f923b 100644 --- a/README.md +++ b/README.md @@ -83,21 +83,30 @@ Wear OS performance, server code quality, no official version endpoint). `adbd` to run commands as shell uid, a foreground service holds the outbound WS, auto-starts on boot. -## Quick start: local Android build and setup +## Quick start: relay server and MCP client -The currently published npm CLI still knows about the legacy download server, -so explicitly disable remote APK download and build from this checkout: +The npm package deliberately handles only server/client setup. It does not +download, build, install, or update the Android APK. ```bash -git clone https://github.com/turin-dev/rish-mcp.git -cd rish-mcp -npx rish-mcp-setup --server= +# interactive menu: install relay server or create MCP client config +npx rish-mcp-setup + +# non-interactive relay install/update +npx rish-mcp-setup --yes --action server + +# non-interactive MCP client config +npx rish-mcp-setup --yes --action client \ + --url https://mcp.example.com/mcp \ + --token "$AI_TOKEN" ``` -This requires Node.js 18+, Docker, and `adb`; it does not install the CLI -globally. The empty `--server=` value is deliberate and forces a local build. -For scripts, add `--yes --action setup|apk|relay`. See -[`cli/README.md`](cli/README.md) for all options and prerequisites. +The server action requires Docker. The client action writes a standard +`mcpServers` entry under `~/.config/rish-mcp/client.json`. Android agent +artifacts are distributed separately through the signed `agent-v*` GitHub +release channel; local Android builds remain documented below. + +See [`cli/README.md`](cli/README.md) for all npm setup options. ## Build From 11d959df89edf5f520c227a283577054e6071319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:44:16 +0900 Subject: [PATCH 10/11] docs: clarify npm package never handles APKs --- docs/RELEASES.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 7878fc5..2c1d4ba 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -48,9 +48,11 @@ unresolved backup, removes non-current immutable APK artifacts. The cache is single-writer: run only one public-server process per `RELEASE_CACHE_DIR`. `APK_PATH` remains an explicit local override and bypasses GitHub polling. -The npm package `rish-mcp-setup` has its own independent semantic version. A -CLI package version is not an Android agent version and must not be used to -select an APK. +The npm package `rish-mcp-setup` has its own independent semantic version and +is limited to relay-server installation and MCP-client configuration. It does +not download, build, install, select, or update Android APKs. Android agent +artifacts are handled only through the `agent-v*` release channel and related +server-side release machinery. `agent-v0.1.0` is currently published as a signed GitHub prerelease for controlled preview testing. It is not a stable release because the real-device From bcbb4d0e285a14bc4332af0a1ee675e5b9629aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=88=AC=EB=A6=B0?= Date: Sun, 13 Sep 2026 12:45:09 +0900 Subject: [PATCH 11/11] security(cli): harden relay container defaults --- cli/index.js | 57 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/cli/index.js b/cli/index.js index a6a56ad..9adb573 100644 --- a/cli/index.js +++ b/cli/index.js @@ -98,7 +98,8 @@ function randomToken() { } function configDir() { - const dir = path.join(homedir(), ".config", "rish-mcp"); + const base = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"); + const dir = path.join(base, "rish-mcp"); mkdirSync(dir, { recursive: true }); return dir; } @@ -111,6 +112,13 @@ function parsePort(raw) { return value; } +function validateEnvValue(name, value) { + if (!value || /[\r\n]/.test(value)) { + throw new Error(`${name} must be non-empty and must not contain newlines`); + } + return value; +} + function readEnvFile(filePath) { if (!existsSync(filePath)) return {}; const result = {}; @@ -127,7 +135,7 @@ function readEnvFile(filePath) { async function chooseAction() { const requested = argValue("--action"); if (requested) { - if (!['server', 'client'].includes(requested)) { + if (!["server", "client"].includes(requested)) { throw new Error(`--action must be server or client (got ${requested})`); } return requested; @@ -158,11 +166,14 @@ async function installServer() { const relayEnvPath = path.join(configDir(), "relay.env"); const previous = readEnvFile(relayEnvPath); - let aiToken = argValue("--ai-token") || process.env.AI_TOKEN || previous.AI_TOKEN || ""; - let deviceToken = argValue("--device-token") || process.env.DEVICE_TOKEN || previous.DEVICE_TOKEN || ""; - - if (!aiToken) aiToken = randomToken(); - if (!deviceToken) deviceToken = randomToken(); + const aiToken = validateEnvValue( + "AI_TOKEN", + argValue("--ai-token") || process.env.AI_TOKEN || previous.AI_TOKEN || randomToken(), + ); + const deviceToken = validateEnvValue( + "DEVICE_TOKEN", + argValue("--device-token") || process.env.DEVICE_TOKEN || previous.DEVICE_TOKEN || randomToken(), + ); writeFileSync( relayEnvPath, @@ -182,15 +193,28 @@ async function installServer() { "docker", [ "run", - "-d", + "--detach", "--name", RELAY_CONTAINER, "--restart", "unless-stopped", + "--publish", + `${port}:8080`, "--env-file", relayEnvPath, - "-p", - `${port}:8080`, + "--env", + "PORT=8080", + "--read-only", + "--tmpfs", + "/tmp:size=64m,noexec,nosuid,nodev", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges:true", + "--log-opt", + "max-size=10m", + "--log-opt", + "max-file=3", RELAY_IMAGE, ], { stdio: "inherit" }, @@ -199,11 +223,10 @@ async function installServer() { console.log(); console.log(good("relay server installed")); - console.log(`MCP URL: http://localhost:${port}/mcp`); - console.log(`AI_TOKEN: ${aiToken}`); - console.log(`DEVICE_TOKEN: ${deviceToken}`); - console.log(`Secrets: ${relayEnvPath}`); - console.log(dim("Keep both tokens secret. The Android app needs DEVICE_TOKEN; MCP clients use AI_TOKEN.")); + console.log(`Local health: http://127.0.0.1:${port}/healthz`); + console.log(`MCP endpoint: http://127.0.0.1:${port}/mcp`); + console.log(`Credentials: ${relayEnvPath}`); + console.log(dim("Place the relay behind HTTPS before exposing it publicly.")); } async function configureClient() { @@ -215,9 +238,7 @@ async function configureClient() { let token = argValue("--token") || process.env.AI_TOKEN || localRelay.AI_TOKEN || ""; if (!token && !nonInteractive) token = await prompt("AI_TOKEN:"); - if (!token) { - throw new Error("AI token is required; pass --token or set AI_TOKEN"); - } + token = validateEnvValue("AI_TOKEN", token); const config = { mcpServers: {