From 1113f2720c1c6030a3099b77ed6bbaafd193dc51 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 15:38:46 +0000 Subject: [PATCH 01/11] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/436 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..d787956e --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-23T15:38:46.439Z for PR creation at branch issue-436-7a79950cbb5b for issue https://github.com/ProverCoderAI/docker-git/issues/436 \ No newline at end of file From 19fdc1995f8f2d28df128bb4d365ecba58ec0a21 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:47:21 +0000 Subject: [PATCH 02/11] chore(release): version packages --- packages/app/CHANGELOG.md | 9 +++++++++ packages/app/package.json | 2 +- packages/docker-git-session-sync/CHANGELOG.md | 6 ++++++ packages/docker-git-session-sync/package.json | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 2cc86b25..d13ddbb3 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,14 @@ # @prover-coder-ai/docker-git +## 1.3.14 + +### Patch Changes + +- chore: automated version bump + +- Updated dependencies []: + - @prover-coder-ai/docker-git-session-sync@1.0.70 + ## 1.3.13 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 2caac76a..07ed7322 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git", - "version": "1.3.13", + "version": "1.3.14", "description": "docker-git Bun and Gridland CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { diff --git a/packages/docker-git-session-sync/CHANGELOG.md b/packages/docker-git-session-sync/CHANGELOG.md index 424da984..0b49da7b 100644 --- a/packages/docker-git-session-sync/CHANGELOG.md +++ b/packages/docker-git-session-sync/CHANGELOG.md @@ -1,5 +1,11 @@ # @prover-coder-ai/docker-git-session-sync +## 1.0.70 + +### Patch Changes + +- chore: automated version bump + ## 1.0.69 ### Patch Changes diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 44aebcee..f8caac85 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.69", + "version": "1.0.70", "description": "Standalone docker-git AI agent session synchronization tool", "main": "dist/docker-git-session-sync.js", "bin": { From c315741af1cb33bab67f96d2ea00e9c3df4e13bf Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 15:59:36 +0000 Subject: [PATCH 03/11] feat(container): render Android MCP (mobile-mcp) integration Mirror the Playwright MCP wiring for Android (issue #436): - TemplateConfig.enableMcpAndroid + default - docker-compose: emulator sidecar service (docker-android), env vars, ADB endpoint, dedicated volume - Dockerfile: install android-tools-adb when enabled - entrypoint base env MCP_ANDROID_ENABLE - codex/claude/gemini/grok: register an 'android' MCP server (mobile-mcp) - container template tests --- packages/container/src/core/domain.ts | 1 + .../container/src/core/template-defaults.ts | 2 + .../src/core/templates-entrypoint.ts | 2 + .../src/core/templates-entrypoint/base.ts | 1 + .../src/core/templates-entrypoint/claude.ts | 57 ++++++++++++ .../src/core/templates-entrypoint/codex.ts | 80 +++++++++++++++++ .../src/core/templates-entrypoint/gemini.ts | 39 +++++++++ .../src/core/templates-entrypoint/grok.ts | 39 +++++++++ .../src/core/templates/docker-compose.ts | 87 +++++++++++++++++-- .../src/core/templates/dockerfile.ts | 18 ++++ .../container/tests/core/templates.test.ts | 69 +++++++++++++++ 11 files changed, 390 insertions(+), 5 deletions(-) diff --git a/packages/container/src/core/domain.ts b/packages/container/src/core/domain.ts index 41d9fda3..7466e2fe 100644 --- a/packages/container/src/core/domain.ts +++ b/packages/container/src/core/domain.ts @@ -74,6 +74,7 @@ export interface TemplateConfig { readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid?: boolean | undefined readonly bunVersion: string readonly agentMode?: AgentMode | undefined readonly agentAuto?: boolean | undefined diff --git a/packages/container/src/core/template-defaults.ts b/packages/container/src/core/template-defaults.ts index b17cbd85..98682b27 100644 --- a/packages/container/src/core/template-defaults.ts +++ b/packages/container/src/core/template-defaults.ts @@ -29,6 +29,7 @@ type DefaultTemplateConfig = Pick< | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" + | "enableMcpAndroid" | "bunVersion" > @@ -74,5 +75,6 @@ export const defaultTemplateConfig = { dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, + enableMcpAndroid: false, bunVersion: "1.3.11" } satisfies DefaultTemplateConfig diff --git a/packages/container/src/core/templates-entrypoint.ts b/packages/container/src/core/templates-entrypoint.ts index 96fdd684..7d74625f 100644 --- a/packages/container/src/core/templates-entrypoint.ts +++ b/packages/container/src/core/templates-entrypoint.ts @@ -17,6 +17,7 @@ import { renderEntrypointCodexHome, renderEntrypointCodexResumeHint, renderEntrypointCodexSharedAuth, + renderEntrypointMcpAndroid, renderEntrypointMcpPlaywright, renderEntrypointProjectCodexSkillsSync } from "./templates-entrypoint/codex.js" @@ -60,6 +61,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointDockerSocket(config), renderEntrypointRustBrowserConnection(), renderEntrypointMcpPlaywright(config), + renderEntrypointMcpAndroid(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), renderEntrypointGeminiConfig(config), diff --git a/packages/container/src/core/templates-entrypoint/base.ts b/packages/container/src/core/templates-entrypoint/base.ts index 0ad47619..5c6be2a1 100644 --- a/packages/container/src/core/templates-entrypoint/base.ts +++ b/packages/container/src/core/templates-entrypoint/base.ts @@ -41,6 +41,7 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-0}" +MCP_ANDROID_ENABLE="\${MCP_ANDROID_ENABLE:-${config.enableMcpAndroid === true ? "1" : "0"}}" SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" diff --git a/packages/container/src/core/templates-entrypoint/claude.ts b/packages/container/src/core/templates-entrypoint/claude.ts index e86f0167..8571e802 100644 --- a/packages/container/src/core/templates-entrypoint/claude.ts +++ b/packages/container/src/core/templates-entrypoint/claude.ts @@ -251,6 +251,62 @@ NODE docker_git_sync_claude_playwright_mcp chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` +const renderClaudeMcpAndroidConfig = (): string => + String.raw`# Claude Code: keep Android MCP config in sync with container settings +CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" +docker_git_sync_claude_android_mcp() { + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_SETTINGS_FILE +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +const enableAndroid = process.env.MCP_ANDROID_ENABLE === "1" +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const raw = fs.readFileSync(settingsPath, "utf8") + const parsed = JSON.parse(raw) + settings = isRecord(parsed) ? parsed : {} +} catch { settings = {} } + +const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} +const nextServers = { ...currentServers } +if (enableAndroid) { + nextServers.android = { + type: "stdio", + command: "bash", + args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], + env: {} + } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +if (Object.keys(nextServers).length > 0) { + nextSettings.mcpServers = nextServers +} else { + delete nextSettings.mcpServers +} + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_claude_android_mcp +chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` + const renderClaudeProfileSetup = (): string => String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh" printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE" @@ -277,6 +333,7 @@ export const renderEntrypointClaudeConfig = (config: TemplateConfig): string => renderClaudeCliInstall(), renderClaudePermissionSettingsConfig(), renderClaudeMcpPlaywrightConfig(), + renderClaudeMcpAndroidConfig(), renderClaudeGlobalPromptSetup(config), renderClaudeWrapperSetup(), renderClaudeProfileSetup() diff --git a/packages/container/src/core/templates-entrypoint/codex.ts b/packages/container/src/core/templates-entrypoint/codex.ts index a43bd715..7b3c7546 100644 --- a/packages/container/src/core/templates-entrypoint/codex.ts +++ b/packages/container/src/core/templates-entrypoint/codex.ts @@ -132,6 +132,86 @@ export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => .replaceAll("__CODEX_HOME__", () => config.codexHome) .replaceAll("__SERVICE_NAME__", () => config.serviceName) +// CHANGE: configure the Android MCP server (mobile-mcp) for Codex, mirroring the Playwright block +// WHY: issue-436 asks to wire mcp-android "the same way" Playwright MCP works; Codex reads its +// MCP servers from config.toml, so we add/remove an [mcp_servers.android] entry to match the build +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/mobile-next/mobile-mcp +const entrypointMcpAndroidTemplate = String.raw`# Optional: configure Android MCP for Codex (mobile-mcp over ADB) +CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" +DOCKER_GIT_ANDROID_ADB_ENDPOINT="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + +# Keep config.toml consistent with the container build. +# If Android MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn the mobile-mcp server. +if [[ "$MCP_ANDROID_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Android MCP/ { next } + /^\[mcp_servers[.]android([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.5" +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +shell_snapshot = true +multi_agent = true +apps = true +shell_tool = true + +[profiles.longcontx] +model = "gpt-5.5" +model_context_window = 1050000 +model_auto_compact_token_limit = 945000 +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + # Replace the docker-git Android MCP block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Android MCP/ { next } + /^\[mcp_servers[.]android([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Android MCP (mobile-mcp over ADB) +[mcp_servers.android] +command = "bash" +args = ["-lc", "adb connect $DOCKER_GIT_ANDROID_ADB_ENDPOINT >/dev/null 2>&1 || true; exec npx -y @mobilenext/mobile-mcp@latest"] +EOF +fi` + +export const renderEntrypointMcpAndroid = (config: TemplateConfig): string => + entrypointMcpAndroidTemplate + .replaceAll("__CODEX_HOME__", () => config.codexHome) + .replaceAll("__SERVICE_NAME__", () => config.serviceName) + const entrypointProjectCodexSkillsSyncTemplate = String .raw`# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. docker_git_sync_project_codex_skills() { diff --git a/packages/container/src/core/templates-entrypoint/gemini.ts b/packages/container/src/core/templates-entrypoint/gemini.ts index e8d35570..42be2e5b 100644 --- a/packages/container/src/core/templates-entrypoint/gemini.ts +++ b/packages/container/src/core/templates-entrypoint/gemini.ts @@ -238,6 +238,44 @@ NODE docker_git_sync_gemini_playwright_mcp` +const renderGeminiMcpAndroidConfig = (): string => + String.raw`# Gemini CLI: keep Android MCP config in sync with container settings +docker_git_sync_gemini_android_mcp() { + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_android_mcp` + const renderGeminiProfileSetup = (config: TemplateConfig): string => String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" printf "export GEMINI_AUTH_LABEL=%q\n" "$GEMINI_AUTH_LABEL" > "$GEMINI_PROFILE" @@ -336,6 +374,7 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => renderGeminiAuthConfig(config), renderGeminiPermissionSettingsConfig(config), renderGeminiMcpPlaywrightConfig(), + renderGeminiMcpAndroidConfig(), renderGeminiSudoConfig(config), renderGeminiProfileSetup(config), entrypointGeminiNoticeTemplate diff --git a/packages/container/src/core/templates-entrypoint/grok.ts b/packages/container/src/core/templates-entrypoint/grok.ts index 86e80bcf..336f172f 100644 --- a/packages/container/src/core/templates-entrypoint/grok.ts +++ b/packages/container/src/core/templates-entrypoint/grok.ts @@ -224,6 +224,44 @@ NODE docker_git_sync_grok_playwright_mcp` +const renderGrokMcpAndroidConfig = (): string => + String.raw`# Grok CLI: keep Android MCP config in sync with container settings +docker_git_sync_grok_android_mcp() { + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_grok_android_mcp` + const renderGrokSudoConfig = (config: TemplateConfig): string => `# Grok CLI: allow passwordless sudo for agent tasks # Risk rationale: Grok runs inside an isolated per-project container. The sshUser @@ -344,6 +382,7 @@ export const renderEntrypointGrokConfig = (config: TemplateConfig): string => renderGrokAuthConfig(config), renderGrokPermissionSettingsConfig(config), renderGrokMcpPlaywrightConfig(), + renderGrokMcpAndroidConfig(), renderGrokSudoConfig(config), renderGrokProfileSetup(config), renderEntrypointGrokNotice(config) diff --git a/packages/container/src/core/templates/docker-compose.ts b/packages/container/src/core/templates/docker-compose.ts index a0b25547..fe8408bf 100644 --- a/packages/container/src/core/templates/docker-compose.ts +++ b/packages/container/src/core/templates/docker-compose.ts @@ -23,6 +23,9 @@ type ComposeFragments = { readonly maybeDockerSocketMount: string readonly maybePlaywrightEnv: string readonly maybeBrowserVolume: string + readonly maybeAndroidEnv: string + readonly maybeAndroidService: string + readonly maybeAndroidVolume: string readonly maybeBootstrapMounts: string readonly forkRepoUrl: string } @@ -35,6 +38,13 @@ type PlaywrightFragments = Pick< | "maybeBrowserVolume" > +type AndroidFragments = Pick< + ComposeFragments, + | "maybeAndroidEnv" + | "maybeAndroidService" + | "maybeAndroidVolume" +> + type AuthEnvFragments = Pick< ComposeFragments, | "maybeGitTokenLabelEnv" @@ -190,6 +200,63 @@ const buildPlaywrightFragments = ( } } +// CHANGE: render an Android emulator sidecar service mirroring the Playwright MCP wiring +// WHY: issue-436 asks to connect mcp-android "the same way" Playwright MCP works, exposing +// a docker-android emulator as a service reachable over ADB for the mobile-mcp server +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/budtmo/docker-android +// PURITY: CORE +// INVARIANT: only emitted when config.enableMcpAndroid === true; image/ports are env-overridable +const defaultAndroidEmulatorImage = "budtmo/docker-android:emulator_14.0" + +const buildAndroidFragments = ( + config: TemplateConfig, + resourceLimits: ResolvedComposeResourceLimits | undefined +): AndroidFragments => { + if (config.enableMcpAndroid !== true) { + return { + maybeAndroidEnv: "", + maybeAndroidService: "", + maybeAndroidVolume: "" + } + } + + const androidContainerName = `${config.containerName}-android` + const androidVolumeName = `${config.volumeName}-android` + const androidImageRef = `\${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-${defaultAndroidEmulatorImage}}` + const networkName = resolveComposeNetworkName(config) + + const maybeAndroidEnv = + ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` + + const maybeAndroidService = ` + ${config.serviceName}-android: + image: "${androidImageRef}" + container_name: ${androidContainerName} + privileged: true + environment: + EMULATOR_DEVICE: "\${DOCKER_GIT_ANDROID_DEVICE:-Samsung Galaxy S10}" + WEB_VNC: "\${DOCKER_GIT_ANDROID_WEB_VNC:-true}" + EMULATOR_HEADLESS: "\${DOCKER_GIT_ANDROID_HEADLESS:-true}" + devices: + - /dev/kvm + ports: + - "\${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555" + - "\${DOCKER_GIT_ANDROID_NOVNC_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_NOVNC_PORT:-6080}:6080" +${renderResourceLimits(resourceLimits)} volumes: + - ${androidVolumeName}:/root/.android + networks: + - ${networkName} +` + + return { + maybeAndroidEnv, + maybeAndroidService, + maybeAndroidVolume: ` ${androidVolumeName}:` + } +} + const isResolvedComposeResourceLimits = ( value: ResolvedComposeResourceLimits | ComposeResourceLimits ): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value @@ -225,6 +292,7 @@ const buildComposeFragments = ( resourceLimits.playwright, options ) + const android = buildAndroidFragments(config, resourceLimits.playwright) return { networkMode, @@ -236,6 +304,9 @@ const buildComposeFragments = ( maybeDockerSocketMount: playwright.maybeDockerSocketMount, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserVolume: playwright.maybeBrowserVolume, + maybeAndroidEnv: android.maybeAndroidEnv, + maybeAndroidService: android.maybeAndroidService, + maybeAndroidVolume: android.maybeAndroidVolume, maybeBootstrapMounts: renderBootstrapMounts(), forkRepoUrl } @@ -269,7 +340,7 @@ ${fragments.maybeGrokAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.mayb DOCKER_GIT_PROJECT_DOCKER_HOST: "\${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" TARGET_DIR: "${config.targetDir}" CODEX_HOME: "${config.codexHome}" -${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap +${fragments.maybePlaywrightEnv}${fragments.maybeAndroidEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap ports: - "\${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-127.0.0.1}:${config.sshPort}:22" ${renderResourceLimits(resourceLimits.main)} volumes: @@ -286,7 +357,7 @@ ${fragments.maybeDockerSocketMount} - 1.1.1.1 networks: - ${fragments.networkName} -` +${fragments.maybeAndroidService}` const renderComposeNetworks = ( networkMode: TemplateConfig["dockerNetworkMode"], @@ -302,7 +373,8 @@ const renderComposeNetworks = ( const renderComposeVolumes = ( config: TemplateConfig, - maybeBrowserVolume: string + maybeBrowserVolume: string, + maybeAndroidVolume: string ): string => [ "volumes:", @@ -315,7 +387,8 @@ const renderComposeVolumes = ( ` ${sharedCodexVolumeKey}:`, " external: true", ` name: ${dockerGitSharedCodexVolumeName}`, - maybeBrowserVolume + maybeBrowserVolume, + maybeAndroidVolume ] .filter((entry) => entry.length > 0) .join("\n") @@ -331,6 +404,10 @@ export const renderDockerCompose = ( `name: ${resolveComposeProjectName(config)}`, renderComposeServices(config, fragments, limits), renderComposeNetworks(fragments.networkMode, fragments.networkName), - renderComposeVolumes(config, fragments.maybeBrowserVolume) + renderComposeVolumes( + config, + fragments.maybeBrowserVolume, + fragments.maybeAndroidVolume + ) ].join("\n\n") } diff --git a/packages/container/src/core/templates/dockerfile.ts b/packages/container/src/core/templates/dockerfile.ts index 14b756f2..e9dd548a 100644 --- a/packages/container/src/core/templates/dockerfile.ts +++ b/packages/container/src/core/templates/dockerfile.ts @@ -91,6 +91,23 @@ const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => # Old browser-vnc + cdp-guard duplication removed per #347` : "" +// CHANGE: install the ADB client when Android MCP is enabled +// WHY: the mobile-mcp server (launched via npx at runtime) drives the docker-android +// emulator sidecar over ADB, so the dev container needs an `adb` binary to connect +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/budtmo/docker-android (ADB on 5555) +// PURITY: CORE (pure template renderer) +const renderDockerfileAndroidRuntime = (config: TemplateConfig): string => + config.enableMcpAndroid === true + ? `# Android MCP runtime: ADB client to reach the dg-*-android emulator sidecar. +# The MCP server itself (@mobilenext/mobile-mcp) is launched on demand via npx (Node 24 above). +RUN apt-get update \ + && apt-get install -y --no-install-recommends android-tools-adb \ + && rm -rf /var/lib/apt/lists/* \ + && adb --version` + : "" + /** * Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension. * @@ -241,6 +258,7 @@ export const renderDockerfile = (config: TemplateConfig): string => renderDockerfileNode(), renderDockerfileBun(config), renderDockerfilePlaywrightRuntime(config), + renderDockerfileAndroidRuntime(config), renderDockerfileRtk(), renderDockerfileOpenCode(), renderDockerfileGitleaks(), diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index b3feec3b..40ef7300 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -1130,6 +1130,75 @@ describe("renderDockerCompose", () => { expect(compose).toContain('DOCKER_GIT_BROWSER_RAM_LIMIT: "${DOCKER_GIT_BROWSER_RAM_LIMIT:-2g}"') }) + it("renders the Android emulator sidecar service when Android MCP is enabled", () => { + const compose = renderDockerCompose( + makeTemplateConfig({ + enableMcpAndroid: true, + gpu: "none", + }), + { + cpuLimit: 1.5, + ramLimit: "2g", + swapLimit: "4g" + } + ) + + expect(compose).toContain('MCP_ANDROID_ENABLE: "1"') + expect(compose).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') + expect(compose).toContain( + 'DOCKER_GIT_ANDROID_ADB_ENDPOINT: "${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-dg-test-android:5555}"' + ) + expect(compose).toContain( + 'DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-budtmo/docker-android:emulator_14.0}"' + ) + // emulator runs as a real compose service (unlike the externally-managed browser container) + expect(compose).toContain("\n dg-test-android:\n") + expect(compose).toContain(" - /dev/kvm") + expect(compose).toContain( + '- "${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555"' + ) + expect(compose).toContain(" dg-test-home-android:") + // the sidecar reuses the Playwright sidecar resource budget + expect(compose).toContain(" cpus: 1.5\n") + }) + + it("omits all Android emulator wiring when Android MCP is disabled", () => { + const compose = renderDockerCompose(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(compose).not.toContain("MCP_ANDROID_ENABLE") + expect(compose).not.toContain("\n dg-test-android:\n") + expect(compose).not.toContain("dg-test-home-android:") + expect(compose).not.toContain("/dev/kvm") + }) + + it("installs the ADB client in the Dockerfile only when Android MCP is enabled", () => { + const enabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: true })) + const disabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(enabled).toContain("android-tools-adb") + expect(enabled).toContain("adb --version") + expect(disabled).not.toContain("android-tools-adb") + }) + + it("configures the Android MCP server for every agent and defaults the enable flag", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpAndroid: true })) + + expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-1}"') + // Codex (TOML) + expect(entrypoint).toContain("[mcp_servers.android]") + expect(entrypoint).toContain("@mobilenext/mobile-mcp@latest") + // Claude / Gemini / Grok (JSON sync helpers) + expect(entrypoint).toContain("docker_git_sync_claude_android_mcp") + expect(entrypoint).toContain("docker_git_sync_gemini_android_mcp") + expect(entrypoint).toContain("docker_git_sync_grok_android_mcp") + }) + + it("defaults MCP_ANDROID_ENABLE to 0 when Android MCP is disabled", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-0}"') + }) + it("renders explicit anonymous GitHub clone override for public repos", () => { const compose = renderDockerCompose( makeTemplateConfig({ From 66ed517fd8e28f410c5402f0bd039fa36aa5b6a2 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 16:07:52 +0000 Subject: [PATCH 04/11] feat(lib): wire Android MCP command + config plumbing --- .../lib/src/core/command-builders-template.ts | 2 + packages/lib/src/core/command-builders.ts | 5 +- packages/lib/src/core/command-options.ts | 1 + packages/lib/src/core/domain.ts | 8 ++ packages/lib/src/shell/config.ts | 3 + packages/lib/src/shell/errors.ts | 2 + .../actions/create-project-conflicts.ts | 18 ++- packages/lib/src/usecases/apply-overrides.ts | 6 +- packages/lib/src/usecases/errors.ts | 2 + packages/lib/src/usecases/mcp-android.ts | 90 ++++++++++++ .../lib/tests/usecases/mcp-android.test.ts | 135 ++++++++++++++++++ 11 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 packages/lib/src/usecases/mcp-android.ts create mode 100644 packages/lib/tests/usecases/mcp-android.test.ts diff --git a/packages/lib/src/core/command-builders-template.ts b/packages/lib/src/core/command-builders-template.ts index d0bcce72..9e7b1f8d 100644 --- a/packages/lib/src/core/command-builders-template.ts +++ b/packages/lib/src/core/command-builders-template.ts @@ -19,6 +19,7 @@ export type BuildTemplateConfigInput = { readonly geminiAuthLabel: string | undefined readonly grokAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly agentMode: AgentMode | undefined readonly agentAuto: boolean /** @@ -95,6 +96,7 @@ export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateComm dockerNetworkMode: input.dockerNetworkMode, dockerSharedNetworkName: input.dockerSharedNetworkName, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, bunVersion: defaultTemplateConfig.bunVersion, agentMode: input.agentMode, agentAuto: input.agentAuto, diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 99c5a058..07515123 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -200,6 +200,7 @@ type CreateBehavior = { readonly force: boolean readonly forceEnv: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean } const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ @@ -208,7 +209,8 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ skipGithubAuth: raw.skipGithubAuth ?? false, force: raw.force ?? false, forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false + enableMcpPlaywright: raw.enableMcpPlaywright ?? false, + enableMcpAndroid: raw.enableMcpAndroid ?? false }) type TokenLabelConfig = { @@ -276,6 +278,7 @@ export const buildCreateCommand = ( ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, enableMcpPlaywright: behavior.enableMcpPlaywright, + enableMcpAndroid: behavior.enableMcpAndroid, agentMode, agentAuto: isAgentAuto, clonedOnHostname: raw.clonedOnHostname diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 036e43df..742a5623 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -33,6 +33,7 @@ export interface RawOptions { readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean + readonly enableMcpAndroid?: boolean readonly archivePath?: string readonly scrapMode?: string readonly wipe?: boolean diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 0a76dd79..de9b9bcf 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -144,6 +144,12 @@ export interface McpPlaywrightUpCommand { readonly runUp: boolean } +export interface McpAndroidUpCommand { + readonly _tag: "McpAndroidUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface ApplyCommand { readonly _tag: "Apply" readonly projectDir: string @@ -159,6 +165,7 @@ export interface ApplyCommand { readonly playwrightRamLimit?: string | undefined readonly gpu?: GpuMode | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined } // CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag @@ -201,6 +208,7 @@ export type Command = | SessionsCommand | ScrapCommand | McpPlaywrightUpCommand + | McpAndroidUpCommand | ApplyCommand | ApplyAllCommand | HelpCommand diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index d165a0a9..b0b45e1e 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -85,6 +85,9 @@ const TemplateConfigInputSchema = Schema.Struct({ enableMcpPlaywright: Schema.optionalWith(Schema.Boolean, { default: () => defaultTemplateConfig.enableMcpPlaywright }), + enableMcpAndroid: Schema.optionalWith(Schema.Boolean, { + default: () => defaultTemplateConfig.enableMcpAndroid + }), bunVersion: Schema.optional(Schema.String), pnpmVersion: Schema.optional(Schema.String), clonedOnHostname: Schema.optional(HostnameSchema) diff --git a/packages/lib/src/shell/errors.ts b/packages/lib/src/shell/errors.ts index 5928f0e8..612216c5 100644 --- a/packages/lib/src/shell/errors.ts +++ b/packages/lib/src/shell/errors.ts @@ -39,6 +39,8 @@ export type DockerIdentityConflictKind = | "serviceName" | "volumeName" | "browserVolumeName" + | "androidContainerName" + | "androidVolumeName" | "bootstrapVolumeName" export type DockerIdentityConflict = { diff --git a/packages/lib/src/usecases/actions/create-project-conflicts.ts b/packages/lib/src/usecases/actions/create-project-conflicts.ts index 21365037..1016e3a9 100644 --- a/packages/lib/src/usecases/actions/create-project-conflicts.ts +++ b/packages/lib/src/usecases/actions/create-project-conflicts.ts @@ -16,7 +16,7 @@ type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor. type DockerIdentityOwner = Pick< TemplateConfig, - "containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright" + "containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright" | "enableMcpAndroid" > type DockerIdentityNamespace = "container" | "composeProject" | "volume" @@ -52,14 +52,30 @@ const resolveBrowserVolumeClaims = ( ? [{ namespace: "volume", kind: "browserVolumeName", name: `${config.volumeName}-browser` }] : [] +const resolveAndroidContainerClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => + config.enableMcpAndroid + ? [{ namespace: "container", kind: "androidContainerName", name: `${config.containerName}-android` }] + : [] + +const resolveAndroidVolumeClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => + config.enableMcpAndroid + ? [{ namespace: "volume", kind: "androidVolumeName", name: `${config.volumeName}-android` }] + : [] + const resolveDockerIdentityClaims = ( config: DockerIdentityOwner ): ReadonlyArray => [ { namespace: "container", kind: "containerName", name: config.containerName }, ...resolveBrowserContainerClaims(config), + ...resolveAndroidContainerClaims(config), { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, { namespace: "volume", kind: "volumeName", name: config.volumeName }, ...resolveBrowserVolumeClaims(config), + ...resolveAndroidVolumeClaims(config), { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } ] diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts index bf10b913..86194525 100644 --- a/packages/lib/src/usecases/apply-overrides.ts +++ b/packages/lib/src/usecases/apply-overrides.ts @@ -12,7 +12,8 @@ const applyOverrideKeys = [ "playwrightCpuLimit", "playwrightRamLimit", "gpu", - "enableMcpPlaywright" + "enableMcpPlaywright", + "enableMcpAndroid" ] satisfies ReadonlyArray export const hasApplyOverrides = (command: ApplyCommand): boolean => @@ -58,6 +59,9 @@ const applyResourceOverrides = (template: TemplateConfig, command: ApplyCommand) if (command.enableMcpPlaywright !== undefined) { next = { ...next, enableMcpPlaywright: command.enableMcpPlaywright } } + if (command.enableMcpAndroid !== undefined) { + next = { ...next, enableMcpAndroid: command.enableMcpAndroid } + } return next } diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 111e0f90..172e37e6 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -126,6 +126,8 @@ const formatDockerIdentityConflictKind = ( serviceName: "compose project name", volumeName: "volume name", browserVolumeName: "browser volume name", + androidContainerName: "android container name", + androidVolumeName: "android volume name", bootstrapVolumeName: "bootstrap volume name" })[kind] diff --git a/packages/lib/src/usecases/mcp-android.ts b/packages/lib/src/usecases/mcp-android.ts new file mode 100644 index 00000000..d6c26a39 --- /dev/null +++ b/packages/lib/src/usecases/mcp-android.ts @@ -0,0 +1,90 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import type { McpAndroidUpCommand, TemplateConfig } from "../core/domain.js" +import { readProjectConfig } from "../shell/config.js" +import { ensureDockerDaemonAccess } from "../shell/docker.js" +import type { + ConfigDecodeError, + ConfigNotFoundError, + DockerAccessError, + DockerCommandError, + FileExistsError, + PortProbeError +} from "../shell/errors.js" +import { writeProjectFiles } from "../shell/files.js" +import { ensureCodexConfigFile } from "./auth-sync.js" +import { runDockerComposeUpWithPortCheck } from "./projects-up.js" + +type McpAndroidFilesError = ConfigNotFoundError | ConfigDecodeError | FileExistsError | PlatformError +type McpAndroidFilesEnv = FileSystem | Path + +const enableInTemplate = (template: TemplateConfig): TemplateConfig => ({ + ...template, + enableMcpAndroid: true +}) + +// CHANGE: enable Android MCP in an existing docker-git project directory (files only) +// WHY: allow adding the nested Android emulator sidecar + mobile-mcp server config without wiping env or volumes +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall p: enable(p) -> template(p).enableMcpAndroid = true +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: does not rewrite .orch/env/project.env (only managed templates + docker-git.json) +// COMPLEXITY: O(n) where n = |managed_files| +export const enableMcpAndroidProjectFiles = ( + projectDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const config = yield* _(readProjectConfig(projectDir)) + const wasAlreadyEnabled = config.template.enableMcpAndroid + const updated = wasAlreadyEnabled ? config.template : enableInTemplate(config.template) + + yield* _( + wasAlreadyEnabled + ? Effect.log("Android MCP is already enabled for this project.") + : Effect.log("Enabling Android MCP for this project (templates only)...") + ) + + yield* _(writeProjectFiles(projectDir, updated, true)) + yield* _(ensureCodexConfigFile(projectDir, updated.codexAuthPath)) + + return updated + }) + +export type McpAndroidUpError = + | McpAndroidFilesError + | DockerAccessError + | DockerCommandError + | PortProbeError + +type McpAndroidUpEnv = McpAndroidFilesEnv | CommandExecutor + +// CHANGE: enable Android MCP in an existing project dir and bring docker compose up +// WHY: upgrade already created containers to support Android automation without forcing full recreation flows +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall p: up(p) -> running(p-android) OR docker_error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: volumes are preserved (no docker compose down -v) +// COMPLEXITY: O(command) +export const mcpAndroidUp = ( + command: McpAndroidUpCommand +): Effect.Effect => + Effect.gen(function*(_) { + const updated = yield* _(enableMcpAndroidProjectFiles(command.projectDir)) + + if (!command.runUp) { + return updated + } + + yield* _(ensureDockerDaemonAccess(process.cwd())) + return yield* _(runDockerComposeUpWithPortCheck(command.projectDir)) + }) diff --git a/packages/lib/tests/usecases/mcp-android.test.ts b/packages/lib/tests/usecases/mcp-android.test.ts new file mode 100644 index 00000000..eed53471 --- /dev/null +++ b/packages/lib/tests/usecases/mcp-android.test.ts @@ -0,0 +1,135 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../src/core/domain.js" +import { enableMcpAndroidProjectFiles } from "../../src/usecases/mcp-android.js" +import { prepareProjectFiles } from "../../src/usecases/actions/prepare-files.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-mcp-android-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + skipGithubAuth: false, + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + enableMcpAndroid: false, + gpu: "none", + bunVersion: "1.3.11" +}) + +const makeProjectConfig = ( + outDir: string, + enableMcpAndroid: boolean, + path: Path.Path +): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + skipGithubAuth: false, + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(outDir, ".docker-git"), + authorizedKeysPath: path.join(outDir, "authorized_keys"), + envGlobalPath: path.join(outDir, ".orch/env/global.env"), + envProjectPath: path.join(outDir, ".orch/env/project.env"), + codexAuthPath: path.join(outDir, ".orch/auth/codex"), + codexSharedAuthPath: path.join(outDir, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + enableMcpAndroid, + bunVersion: "1.3.11" +}) + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const readEnableMcpAndroidFlag = (value: unknown): boolean | undefined => { + if (!isRecord(value)) { + return undefined + } + + const template = value.template + if (!isRecord(template)) { + return undefined + } + + const flag = template.enableMcpAndroid + return typeof flag === "boolean" ? flag : undefined +} + +describe("enableMcpAndroidProjectFiles", () => { + it.effect("enables Android MCP for an existing project without rewriting env files", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const outDir = path.join(root, "project") + const globalConfig = makeGlobalConfig(root, path) + const withoutMcp = makeProjectConfig(outDir, false, path) + + yield* _( + prepareProjectFiles(outDir, root, globalConfig, withoutMcp, { + force: false, + forceEnv: false + }) + ) + + const envProjectPath = path.join(outDir, ".orch/env/project.env") + yield* _(fs.writeFileString(envProjectPath, "# custom env\nCUSTOM_KEY=1\n")) + + yield* _(enableMcpAndroidProjectFiles(outDir)) + + const envAfter = yield* _(fs.readFileString(envProjectPath)) + expect(envAfter).toContain("CUSTOM_KEY=1") + + const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) + expect(composeAfter).toContain("dg-test-android") + expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') + expect(composeAfter).toContain("/dev/kvm") + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain("android-tools-adb") + + const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) + const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText))) + expect(readEnableMcpAndroidFlag(configAfter)).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) From 8ef41671de0c8a705205589cbd797b239044a57d Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 16:24:31 +0000 Subject: [PATCH 05/11] feat(app): wire Android MCP through CLI, menu, and create flow Add --mcp-android / --no-mcp-android flags, mcp-android subcommand parser, interactive create-flow prompt + display-settings row, and propagate enableMcpAndroid through frontend-lib config, web create draft, and API client request body. Mirrors the existing Playwright MCP integration. REF: issue-436 --- .../app/src/docker-git/api-client-create.ts | 1 + .../app/src/docker-git/cli/parser-apply.ts | 3 ++- .../src/docker-git/cli/parser-mcp-android.ts | 24 +++++++++++++++++++ .../app/src/docker-git/cli/parser-options.ts | 2 ++ packages/app/src/docker-git/cli/parser.ts | 2 ++ packages/app/src/docker-git/cli/usage.ts | 3 +++ .../core/command-builders-template.ts | 2 ++ .../frontend-lib/core/command-builders.ts | 5 +++- .../frontend-lib/core/command-options.ts | 1 + .../docker-git/frontend-lib/core/domain.ts | 9 +++++++ .../frontend-lib/core/template-defaults.ts | 2 ++ .../docker-git/menu-create-command-parse.ts | 5 ++++ .../app/src/docker-git/menu-create-draft.ts | 2 ++ .../app/src/docker-git/menu-create-inputs.ts | 1 + .../app/src/docker-git/menu-create-labels.ts | 13 ++++++++++ .../src/docker-git/menu-create-navigation.ts | 1 + .../src/docker-git/menu-create-step-apply.ts | 4 +++- .../app/src/docker-git/menu-create-steps.ts | 1 + packages/app/src/docker-git/menu-types.ts | 3 +++ .../app/src/docker-git/program-unsupported.ts | 5 ++++ .../app/src/web/api-project-create-body.ts | 2 ++ packages/app/src/web/api-types.ts | 1 + .../docker-git/actions-project-create.test.ts | 1 + .../docker-git/api-create-project.test.ts | 2 ++ .../docker-git/app-ready-create-fixture.ts | 1 + .../app-ready-create-settings.test.ts | 4 ++-- .../tests/docker-git/app-ready-create.test.ts | 3 ++- .../menu-create-display-settings.test.ts | 9 ++++--- .../menu-create-shared-properties.test.ts | 5 +++- .../docker-git/menu-create-shared.test.ts | 8 +++++-- 30 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/docker-git/cli/parser-mcp-android.ts diff --git a/packages/app/src/docker-git/api-client-create.ts b/packages/app/src/docker-git/api-client-create.ts index 76231dd9..4ed30cce 100644 --- a/packages/app/src/docker-git/api-client-create.ts +++ b/packages/app/src/docker-git/api-client-create.ts @@ -41,6 +41,7 @@ export const buildCreateProjectRequest = ( dockerNetworkMode: config.dockerNetworkMode, dockerSharedNetworkName: config.dockerSharedNetworkName, enableMcpPlaywright: config.enableMcpPlaywright, + enableMcpAndroid: config.enableMcpAndroid ?? false, outDir: command.outDir, gitTokenLabel: config.gitTokenLabel, skipGithubAuth: config.skipGithubAuth, diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 3e6ac8b5..7af24f79 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -40,6 +40,7 @@ export const parseApply = ( playwrightCpuLimit, playwrightRamLimit, gpu, - enableMcpPlaywright: raw.enableMcpPlaywright + enableMcpPlaywright: raw.enableMcpPlaywright, + enableMcpAndroid: raw.enableMcpAndroid } }) diff --git a/packages/app/src/docker-git/cli/parser-mcp-android.ts b/packages/app/src/docker-git/cli/parser-mcp-android.ts new file mode 100644 index 00000000..9c24f94d --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-mcp-android.ts @@ -0,0 +1,24 @@ +import { Either } from "effect" + +import { type McpAndroidUpCommand, type ParseError } from "../frontend-lib/core/domain.js" + +import { parseProjectDirWithOptions } from "./parser-shared.js" + +// CHANGE: parse "mcp-android" command for existing docker-git projects +// WHY: allow enabling Android MCP in an already created container/project dir +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall argv: parseMcpAndroid(argv) = cmd -> deterministic(cmd) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: projectDir is never empty +// COMPLEXITY: O(n) where n = |argv| +export const parseMcpAndroid = ( + args: ReadonlyArray +): Either.Either => + Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ + _tag: "McpAndroidUp", + projectDir, + runUp: raw.up ?? true + })) diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 915e7c09..f1fc8177 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -110,6 +110,8 @@ const booleanFlagUpdaters: Readonly RawOptio "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), "--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }), + "--mcp-android": (raw) => ({ ...raw, enableMcpAndroid: true }), + "--no-mcp-android": (raw) => ({ ...raw, enableMcpAndroid: false }), "--wipe": (raw) => ({ ...raw, wipe: true }), "--no-wipe": (raw) => ({ ...raw, wipe: false }), "--web": (raw) => ({ ...raw, authWeb: true }), diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index b3fb68a5..e01abde2 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -7,6 +7,7 @@ import { parseAttach } from "./parser-attach.js" import { parseAuth } from "./parser-auth.js" import { parseClone } from "./parser-clone.js" import { buildCreateCommand } from "./parser-create.js" +import { parseMcpAndroid } from "./parser-mcp-android.js" import { parseMcpPlaywright } from "./parser-mcp-playwright.js" import { parseOpen } from "./parser-open.js" import { parseRawOptions } from "./parser-options.js" @@ -93,6 +94,7 @@ export const parseArgs = (args: ReadonlyArray): Either.Either parseSessions(rest)), Match.when("scrap", () => parseScrap(rest)), Match.when("mcp-playwright", () => parseMcpPlaywright(rest)), + Match.when("mcp-android", () => parseMcpAndroid(rest)), Match.when("help", () => Either.right(helpCommand)), Match.when("ps", () => Either.right(statusCommand)), Match.when("status", () => Either.right(statusCommand)), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 8de32045..9c0dceaf 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -7,6 +7,7 @@ docker-git clone [options] docker-git open [] [options] docker-git apply [] [options] docker-git mcp-playwright [] [options] +docker-git mcp-android [] [options] docker-git attach [] [options] docker-git panes [] [options] docker-git scrap [] [options] @@ -27,6 +28,7 @@ Commands: open Open an existing docker-git project by selector, URL, or path apply Apply docker-git config to an existing project/container (current dir by default) mcp-playwright Enable Playwright MCP + nested Chromium browser for an existing project dir + mcp-android Enable Android MCP (mobile-mcp) + nested Android emulator for an existing project dir attach, tmux Attach to an existing docker-git project workspace with tmux panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) @@ -78,6 +80,7 @@ Options: --up | --no-up Run docker compose up after init (default: --up) --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright) + --mcp-android | --no-mcp-android Enable Android MCP (mobile-mcp) + nested Android emulator sidecar (default: --no-mcp-android) --auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available -d, --daemon browser: run the browser frontend server in the background after build --active apply-all: apply only to currently running containers (skip stopped ones) diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts index 152732f1..a5839cf5 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts @@ -20,6 +20,7 @@ export type BuildTemplateConfigInput = { readonly geminiAuthLabel: string | undefined readonly grokAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly agentMode: AgentMode | undefined readonly agentAuto: boolean /** @@ -96,6 +97,7 @@ export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateComm dockerNetworkMode: input.dockerNetworkMode, dockerSharedNetworkName: input.dockerSharedNetworkName, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, bunVersion: defaultTemplateConfig.bunVersion, agentMode: input.agentMode, agentAuto: input.agentAuto, diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index 5715ce52..421633f9 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -201,6 +201,7 @@ type CreateBehavior = { readonly force: boolean readonly forceEnv: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean } const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ @@ -209,7 +210,8 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ skipGithubAuth: raw.skipGithubAuth ?? false, force: raw.force ?? false, forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false + enableMcpPlaywright: raw.enableMcpPlaywright ?? false, + enableMcpAndroid: raw.enableMcpAndroid ?? false }) type TokenLabelConfig = { @@ -277,6 +279,7 @@ export const buildCreateCommand = ( ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, enableMcpPlaywright: behavior.enableMcpPlaywright, + enableMcpAndroid: behavior.enableMcpAndroid, agentMode, agentAuto: isAgentAuto, clonedOnHostname: raw.clonedOnHostname diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 8dbbf70f..7c52b036 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -34,6 +34,7 @@ export interface RawOptions { readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean + readonly enableMcpAndroid?: boolean readonly archivePath?: string readonly scrapMode?: string readonly wipe?: boolean diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index 525e1f28..5a77da7b 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -114,6 +114,7 @@ export interface TemplateConfig { readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid?: boolean | undefined readonly bunVersion: string readonly agentMode?: AgentMode | undefined readonly agentAuto?: boolean | undefined @@ -204,6 +205,12 @@ export interface McpPlaywrightUpCommand { readonly runUp: boolean } +export interface McpAndroidUpCommand { + readonly _tag: "McpAndroidUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface ApplyCommand { readonly _tag: "Apply" readonly projectDir: string @@ -219,6 +226,7 @@ export interface ApplyCommand { readonly playwrightRamLimit?: string | undefined readonly gpu?: GpuMode | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined } // CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag @@ -262,6 +270,7 @@ export type Command = | SessionsCommand | ScrapCommand | McpPlaywrightUpCommand + | McpAndroidUpCommand | ApplyCommand | ApplyAllCommand | HelpCommand diff --git a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts index b3bc52c1..c7b2f071 100644 --- a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts +++ b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts @@ -30,6 +30,7 @@ type DefaultTemplateConfig = Pick< | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" + | "enableMcpAndroid" | "bunVersion" > @@ -75,6 +76,7 @@ export const defaultTemplateConfig = { dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, + enableMcpAndroid: false, bunVersion: "1.3.11" } satisfies DefaultTemplateConfig /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/menu-create-command-parse.ts b/packages/app/src/docker-git/menu-create-command-parse.ts index c056bdd5..d3f49977 100644 --- a/packages/app/src/docker-git/menu-create-command-parse.ts +++ b/packages/app/src/docker-git/menu-create-command-parse.ts @@ -113,6 +113,7 @@ const unsupportedCreatePrefixes = new Set([ "gists", "help", "kill-all", + "mcp-android", "mcp-playwright", "menu", "open", @@ -164,6 +165,9 @@ const runUpCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partia const playwrightCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => raw.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: command.config.enableMcpPlaywright } +const androidCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.enableMcpAndroid === undefined ? {} : { enableMcpAndroid: command.config.enableMcpAndroid ?? false } + const forceCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => raw.force === undefined ? {} : { force: command.force } @@ -183,6 +187,7 @@ const createInputsFromCommand = ( ...gpuCreateInput(raw, command), ...runUpCreateInput(raw, command), ...playwrightCreateInput(raw, command), + ...androidCreateInput(raw, command), ...forceCreateInput(raw, command), ...forceEnvCreateInput(raw, command) }) diff --git a/packages/app/src/docker-git/menu-create-draft.ts b/packages/app/src/docker-git/menu-create-draft.ts index 478b4065..351caf1c 100644 --- a/packages/app/src/docker-git/menu-create-draft.ts +++ b/packages/app/src/docker-git/menu-create-draft.ts @@ -12,6 +12,7 @@ export const createProjectDraftFromInputs = ( readonly gpu: GpuMode readonly up: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean } => ({ @@ -23,6 +24,7 @@ export const createProjectDraftFromInputs = ( gpu: input.gpu, up: input.runUp, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, force: input.force, forceEnv: input.forceEnv }) diff --git a/packages/app/src/docker-git/menu-create-inputs.ts b/packages/app/src/docker-git/menu-create-inputs.ts index bbeb9dfc..8d92e6c1 100644 --- a/packages/app/src/docker-git/menu-create-inputs.ts +++ b/packages/app/src/docker-git/menu-create-inputs.ts @@ -247,6 +247,7 @@ export const resolveCreateInputs = ( gpu: values.gpu ?? defaultTemplateConfig.gpu, runUp: values.runUp !== false, enableMcpPlaywright: values.enableMcpPlaywright === true, + enableMcpAndroid: values.enableMcpAndroid === true, force: values.force === true, forceEnv: values.forceEnv === true } diff --git a/packages/app/src/docker-git/menu-create-labels.ts b/packages/app/src/docker-git/menu-create-labels.ts index 35459ffc..1cbaa235 100644 --- a/packages/app/src/docker-git/menu-create-labels.ts +++ b/packages/app/src/docker-git/menu-create-labels.ts @@ -23,6 +23,13 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): renderExplicitBooleanChoice(defaults.enableMcpPlaywright) }]` ), + Match.when( + "mcpAndroid", + () => + `Enable Android MCP (nested Android emulator)? [${ + renderExplicitBooleanChoice(defaults.enableMcpAndroid) + }]` + ), Match.when( "force", () => `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(defaults.force)}]` @@ -57,6 +64,12 @@ export const renderCreateStepLabelWithBufferPreview = ( ? renderCreateStepLabel(step, defaults) : `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]` }), + Match.when("mcpAndroid", () => { + const enableMcpAndroid = parseExplicitBooleanChoice(buffer) + return enableMcpAndroid === null + ? renderCreateStepLabel(step, defaults) + : `Enable Android MCP (nested Android emulator)? [${renderExplicitBooleanChoice(enableMcpAndroid)}]` + }), Match.when("force", () => { const force = parseExplicitBooleanChoice(buffer) return force === null diff --git a/packages/app/src/docker-git/menu-create-navigation.ts b/packages/app/src/docker-git/menu-create-navigation.ts index 89462acb..f0d7b364 100644 --- a/packages/app/src/docker-git/menu-create-navigation.ts +++ b/packages/app/src/docker-git/menu-create-navigation.ts @@ -224,6 +224,7 @@ export const resolveCreateSettingsChoiceBuffer = ( Match.when("gpu", () => gpuChoiceBuffer(direction)), Match.when("runUp", () => booleanChoiceBuffer(direction)), Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)), + Match.when("mcpAndroid", () => booleanChoiceBuffer(direction)), Match.when("force", () => booleanChoiceBuffer(direction)), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-create-step-apply.ts b/packages/app/src/docker-git/menu-create-step-apply.ts index 66c5dad4..b48baf63 100644 --- a/packages/app/src/docker-git/menu-create-step-apply.ts +++ b/packages/app/src/docker-git/menu-create-step-apply.ts @@ -41,12 +41,13 @@ const applyGpuStep = ( const applyBooleanStep = ( input: ApplyCreateStepInput, - key: "runUp" | "enableMcpPlaywright" | "force" + key: "runUp" | "enableMcpPlaywright" | "enableMcpAndroid" | "force" ): Either.Either>, ParseError> => { const isValue = isYesDefault(input.buffer, input.currentDefaults[key]) return Match.value(key).pipe( Match.when("runUp", () => Either.right({ runUp: isValue })), Match.when("enableMcpPlaywright", () => Either.right({ enableMcpPlaywright: isValue })), + Match.when("enableMcpAndroid", () => Either.right({ enableMcpAndroid: isValue })), Match.when("force", () => Either.right({ force: isValue })), Match.exhaustive ) @@ -64,6 +65,7 @@ const applyCreateStep = ( Match.when("gpu", () => applyGpuStep(input)), Match.when("runUp", () => applyBooleanStep(input, "runUp")), Match.when("mcpPlaywright", () => applyBooleanStep(input, "enableMcpPlaywright")), + Match.when("mcpAndroid", () => applyBooleanStep(input, "enableMcpAndroid")), Match.when("force", () => applyBooleanStep(input, "force")), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-create-steps.ts b/packages/app/src/docker-git/menu-create-steps.ts index 73083ce2..63f3d801 100644 --- a/packages/app/src/docker-git/menu-create-steps.ts +++ b/packages/app/src/docker-git/menu-create-steps.ts @@ -19,6 +19,7 @@ const isCreateStepSatisfied = ( Match.when("gpu", () => hasOwn(values, "gpu")), Match.when("runUp", () => hasOwn(values, "runUp")), Match.when("mcpPlaywright", () => hasOwn(values, "enableMcpPlaywright")), + Match.when("mcpAndroid", () => hasOwn(values, "enableMcpAndroid")), Match.when("force", () => hasOwn(values, "force")), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 17c7eff1..84bc9731 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -55,6 +55,7 @@ export type CreateInputs = { readonly gpu: GpuMode readonly runUp: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean } @@ -68,6 +69,7 @@ export type CreateStep = | "gpu" | "runUp" | "mcpPlaywright" + | "mcpAndroid" | "force" export const orderedCreateSteps: ReadonlyArray = [ @@ -77,6 +79,7 @@ export const orderedCreateSteps: ReadonlyArray = [ "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ] diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index bb33082f..1585f428 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -4,6 +4,7 @@ export type UnsupportedOperationalCommandTag = | "ScrapExport" | "ScrapImport" | "McpPlaywrightUp" + | "McpAndroidUp" | "Apply" | "AuthClaudeStatus" | "AuthClaudeLogout" @@ -22,6 +23,10 @@ export const unsupportedOperationalCommands: Record< command: "mcp-playwright", message: "Playwright browser management is disabled in API-only host mode." }, + McpAndroidUp: { + command: "mcp-android", + message: "Android emulator management is disabled in API-only host mode." + }, Apply: { command: "Apply", message: "Command Apply is not available in API-only host mode." diff --git a/packages/app/src/web/api-project-create-body.ts b/packages/app/src/web/api-project-create-body.ts index db0f7019..1a86e48e 100644 --- a/packages/app/src/web/api-project-create-body.ts +++ b/packages/app/src/web/api-project-create-body.ts @@ -44,6 +44,7 @@ export type OptionalProjectResourceFieldsBody = Readonly<{ export type BaseCreateProjectBody = Readonly<{ readonly cpuLimit: CreateProjectDraft["cpuLimit"] readonly enableMcpPlaywright: CreateProjectDraft["enableMcpPlaywright"] + readonly enableMcpAndroid: CreateProjectDraft["enableMcpAndroid"] readonly force: CreateProjectDraft["force"] readonly forceEnv: CreateProjectDraft["forceEnv"] readonly gpu: CreateProjectDraft["gpu"] @@ -94,6 +95,7 @@ export const optionalProjectResourceFields = ( export const baseCreateProjectBody = (draft: CreateProjectDraft): BaseCreateProjectBody => ({ cpuLimit: draft.cpuLimit, enableMcpPlaywright: draft.enableMcpPlaywright, + enableMcpAndroid: draft.enableMcpAndroid, force: draft.force, forceEnv: draft.forceEnv, gpu: draft.gpu, diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 0d564832..31769ef5 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -69,6 +69,7 @@ export type CreateProjectDraft = { readonly ramLimit: string readonly gpu: "none" | "all" readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean readonly up: boolean diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts index 39ac709f..278d5abf 100644 --- a/packages/app/tests/docker-git/actions-project-create.test.ts +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -26,6 +26,7 @@ vi.mock("../../src/web/project-events.js", () => ({ const inputConfig = { cpuLimit: "75%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/api-create-project.test.ts b/packages/app/tests/docker-git/api-create-project.test.ts index 21b944c4..1d2d358e 100644 --- a/packages/app/tests/docker-git/api-create-project.test.ts +++ b/packages/app/tests/docker-git/api-create-project.test.ts @@ -8,6 +8,7 @@ import type { CreateProjectRequestDraft } from "../../src/web/api-project-create const projectDraft = { cpuLimit: "80%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", @@ -61,6 +62,7 @@ describe("api create project request body", () => { async: true, cpuLimit: "80%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts index 85fbdf5b..80e9d1bf 100644 --- a/packages/app/tests/docker-git/app-ready-create-fixture.ts +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -40,6 +40,7 @@ export const validGithubStatus: GithubAuthStatus = { const defaultQuickCreateInputs = { cpuLimit: "", enableMcpPlaywright: false, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/app-ready-create-settings.test.ts b/packages/app/tests/docker-git/app-ready-create-settings.test.ts index ae2c4b45..47ae7ea6 100644 --- a/packages/app/tests/docker-git/app-ready-create-settings.test.ts +++ b/packages/app/tests/docker-git/app-ready-create-settings.test.ts @@ -124,7 +124,7 @@ describe("app-ready-create settings", () => { const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) expect(enterResult.handled).toBe(true) - expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(enteredView.values.enableMcpPlaywright).toBe(true) expect(enteredView.buffer).toBe("") }) @@ -135,7 +135,7 @@ describe("app-ready-create settings", () => { const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) expect(isHandled).toBe(true) - expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(nextView.values.enableMcpPlaywright).toBeUndefined() expect(nextView.buffer).toBe("") }) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 3de81b62..4e655099 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -55,7 +55,8 @@ describe("app-ready-create", () => { "ramLimit", "gpu", "runUp", - "mcpPlaywright" + "mcpPlaywright", + "mcpAndroid" ]) expect(context.setMessage).toHaveBeenCalledWith(null) }) diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts index 1aebb66f..f4445a70 100644 --- a/packages/app/tests/docker-git/menu-create-display-settings.test.ts +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -34,10 +34,11 @@ describe("menu-create-shared display settings", () => { const cwd = process.cwd() const isDisplaySettingStep = (step: CreateStep): step is Exclude => step !== "repoUrl" const displaySettingSteps = resolveCreateDisplaySteps().filter(isDisplaySettingStep) - const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "force"> = [ + const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "mcpAndroid" | "force"> = [ "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ] const validBufferByStep: Record = { @@ -45,6 +46,7 @@ describe("menu-create-shared display settings", () => { force: "y", gpu: "all", mcpPlaywright: "y", + mcpAndroid: "y", outDir: "/home/dev/.docker-git/org/repo-preview", ramLimit: "8g", repoRef: "main", @@ -69,6 +71,7 @@ describe("menu-create-shared display settings", () => { "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ]) }) @@ -90,7 +93,7 @@ describe("menu-create-shared display settings", () => { { ...mcpPlaywrightView, buffer: "y" } ))) - expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(next.buffer).toBe("") expect(next.values.enableMcpPlaywright).toBe(true) }) @@ -123,7 +126,7 @@ describe("menu-create-shared display settings", () => { const down = moveCreateDisplaySettingsStep(applied, "down") const up = moveCreateDisplaySettingsStep(applied, "up") - expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) expect(down?.buffer).toBe("") expect(up?.values.enableMcpPlaywright).toBe(true) diff --git a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts index 7fe2b3ae..fea34597 100644 --- a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts @@ -14,6 +14,7 @@ const settingsStepArbitrary: fc.Arbitrary = fc.constantFrom( "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ) @@ -22,12 +23,13 @@ const stepBufferByStep: Readonly> = { force: "y", gpu: "all", mcpPlaywright: "n", + mcpAndroid: "n", ramLimit: "4g", runUp: "y" } const satisfiedCreateSettingsArbitrary = fc.uniqueArray(settingsStepArbitrary, { - maxLength: 6 + maxLength: 7 }) /** @@ -60,6 +62,7 @@ const createSatisfiedStepValue = (step: CreateSettingStep): Partial => ({ gpu: "none" })), Match.when("runUp", (): Partial => ({ runUp: true })), Match.when("mcpPlaywright", (): Partial => ({ enableMcpPlaywright: false })), + Match.when("mcpAndroid", (): Partial => ({ enableMcpAndroid: false })), Match.when("force", (): Partial => ({ force: false })), Match.exhaustive ) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 63177803..a41a475d 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -58,6 +58,7 @@ describe("menu-create-shared", () => { "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ]) }) @@ -87,7 +88,8 @@ describe("menu-create-shared", () => { "repoUrl", "cpuLimit", "ramLimit", - "gpu" + "gpu", + "mcpAndroid" ]) }) @@ -110,7 +112,7 @@ describe("menu-create-shared", () => { const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, createInitialFlowView( - `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force` + `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --mcp-android --force` ) )) @@ -120,6 +122,7 @@ describe("menu-create-shared", () => { expect(inputs.gpu).toBe("all") expect(inputs.runUp).toBe(false) expect(inputs.enableMcpPlaywright).toBe(true) + expect(inputs.enableMcpAndroid).toBe(true) expect(inputs.force).toBe(true) }) @@ -304,6 +307,7 @@ describe("menu-create-shared", () => { const generatedSettingsArbitrary = fc.record({ cpuLimit: fc.constantFrom("", "25%", "50%"), enableMcpPlaywright: fc.boolean(), + enableMcpAndroid: fc.boolean(), force: fc.boolean(), gpu: fc.constantFrom("none", "all"), ramLimit: fc.constantFrom("", "2g", "4g"), From 3cd6aa804973e43cc3f2b9ec183cf0c8b95bb468 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 16:29:09 +0000 Subject: [PATCH 06/11] feat(api): accept enableMcpAndroid in create-project request + OpenAPI Add enableMcpAndroid to the create-project schema, contract type, and service request mapping, and regenerate the OpenAPI spec + typed paths. REF: issue-436 --- packages/api/src/api/contracts.ts | 1 + packages/api/src/api/schema.ts | 1 + packages/api/src/services/projects.ts | 1 + packages/openapi/openapi.json | 3 +++ packages/openapi/src/openapi-paths.ts | 1 + 5 files changed, 7 insertions(+) diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e6efd92d..5abcdee4 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -476,6 +476,7 @@ export type CreateProjectRequest = { readonly dockerNetworkMode?: string | undefined readonly dockerSharedNetworkName?: string | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined readonly outDir?: string | undefined readonly gitTokenLabel?: string | undefined readonly skipGithubAuth?: boolean | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 6412840b..a91aed32 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -43,6 +43,7 @@ export const CreateProjectRequestSchema = Schema.Struct({ dockerNetworkMode: OptionalString, dockerSharedNetworkName: OptionalString, enableMcpPlaywright: OptionalBoolean, + enableMcpAndroid: OptionalBoolean, outDir: OptionalString, gitTokenLabel: OptionalString, skipGithubAuth: OptionalBoolean, diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 5f79f94f..173c2626 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -471,6 +471,7 @@ const toCreateRawOptions = (request: CreateProjectRequest): RawOptions => ({ ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }), ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), + ...(request.enableMcpAndroid === undefined ? {} : { enableMcpAndroid: request.enableMcpAndroid }), ...(request.outDir === undefined ? {} : { outDir: request.outDir }), ...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }), ...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }), diff --git a/packages/openapi/openapi.json b/packages/openapi/openapi.json index 1e797e52..5d9d6c4d 100644 --- a/packages/openapi/openapi.json +++ b/packages/openapi/openapi.json @@ -1081,6 +1081,9 @@ "enableMcpPlaywright": { "type": "boolean" }, + "enableMcpAndroid": { + "type": "boolean" + }, "outDir": { "type": "string" }, diff --git a/packages/openapi/src/openapi-paths.ts b/packages/openapi/src/openapi-paths.ts index 21e1bdd6..c38bc92f 100644 --- a/packages/openapi/src/openapi-paths.ts +++ b/packages/openapi/src/openapi-paths.ts @@ -1188,6 +1188,7 @@ export interface operations { dockerNetworkMode?: string; dockerSharedNetworkName?: string; enableMcpPlaywright?: boolean; + enableMcpAndroid?: boolean; outDir?: string; gitTokenLabel?: string; skipGithubAuth?: boolean; From 5e1e25bad6d94cd75c32fdc0fd6712f72a6e7d11 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 16:32:34 +0000 Subject: [PATCH 07/11] docs(changeset): document Android MCP integration REF: issue-436 --- .changeset/mcp-android-integration.md | 14 ++++++++++++++ README.md | 1 + 2 files changed, 15 insertions(+) create mode 100644 .changeset/mcp-android-integration.md diff --git a/.changeset/mcp-android-integration.md b/.changeset/mcp-android-integration.md new file mode 100644 index 00000000..bf1502f1 --- /dev/null +++ b/.changeset/mcp-android-integration.md @@ -0,0 +1,14 @@ +--- +"@prover-coder-ai/docker-git": minor +--- + +Add Android MCP integration alongside the existing Playwright MCP support (issue #436). + +Projects can now opt into a nested Android emulator sidecar driven by the +`@mobilenext/mobile-mcp` server, mirroring how Playwright MCP works. Enable it +with the new `--mcp-android` / `--no-mcp-android` create flags, the `mcp-android` +subcommand, the interactive create-flow prompt, or the `enableMcpAndroid` field +on the web/API create-project request. When enabled, the generated +`docker-compose.yml` adds a gated `docker-android` emulator service (KVM, +ADB port forwarding, headless CI mode) and the agent MCP config writers register +the Android server so it coexists with Playwright. diff --git a/README.md b/README.md index cda52047..e232e522 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ bun run docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 - `--force` пересоздаёт окружение и удаляет volumes проекта. - `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации. +- `--mcp-android` включает Android MCP (`@mobilenext/mobile-mcp`) и вложенный sidecar с Android-эмулятором (`docker-android`) для мобильной автоматизации. Автоматический запуск агента: From a80a75ca08bbd1b05e53d371abdbfcf5d338995e Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 16:51:14 +0000 Subject: [PATCH 08/11] refactor(container): extract Android/Playwright MCP helpers to satisfy max-lines lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Android and Playwright MCP config/sidecar builders into sibling modules so grok.ts, gemini.ts, and docker-compose.ts stay under the 300-line max-lines budget enforced by CI. Pure refactor — rendered output is unchanged (72 container tests still pass). REF: issue-436 --- .../gemini-android-mcp.ts | 43 ++++++ .../gemini-playwright-mcp.ts | 42 ++++++ .../src/core/templates-entrypoint/gemini.ts | 78 +---------- .../templates-entrypoint/grok-android-mcp.ts | 43 ++++++ .../src/core/templates-entrypoint/grok.ts | 39 +----- .../core/templates/docker-compose-android.ts | 65 +++++++++ .../templates/docker-compose-playwright.ts | 61 +++++++++ .../src/core/templates/docker-compose.ts | 125 +----------------- 8 files changed, 263 insertions(+), 233 deletions(-) create mode 100644 packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts create mode 100644 packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts create mode 100644 packages/container/src/core/templates-entrypoint/grok-android-mcp.ts create mode 100644 packages/container/src/core/templates/docker-compose-android.ts create mode 100644 packages/container/src/core/templates/docker-compose-playwright.ts diff --git a/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts new file mode 100644 index 00000000..180f55a0 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts @@ -0,0 +1,43 @@ +// CHANGE: extract the Gemini Android MCP config sync into its own module +// WHY: issue-436 wires mcp-android "the same way" Playwright MCP works; keeping the +// render helper in a dedicated file keeps gemini.ts under the max-lines lint budget +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/mobile-next/mobile-mcp +export const renderGeminiMcpAndroidConfig = (): string => + String.raw`# Gemini CLI: keep Android MCP config in sync with container settings +docker_git_sync_gemini_android_mcp() { + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_android_mcp` diff --git a/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts new file mode 100644 index 00000000..c4dea291 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts @@ -0,0 +1,42 @@ +// CHANGE: house the Gemini Playwright MCP config sync in its own module +// WHY: gemini.ts also wires the Android MCP sidecar (issue-436); moving both optional MCP +// config helpers into sibling modules keeps gemini.ts under the max-lines lint budget +// REF: issue-436 +export const renderGeminiMcpPlaywrightConfig = (): string => + String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_gemini_playwright_mcp() { + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_playwright_mcp` diff --git a/packages/container/src/core/templates-entrypoint/gemini.ts b/packages/container/src/core/templates-entrypoint/gemini.ts index 42be2e5b..48599300 100644 --- a/packages/container/src/core/templates-entrypoint/gemini.ts +++ b/packages/container/src/core/templates-entrypoint/gemini.ts @@ -1,4 +1,6 @@ import type { TemplateConfig } from "../domain.js" +import { renderGeminiMcpAndroidConfig } from "./gemini-android-mcp.js" +import { renderGeminiMcpPlaywrightConfig } from "./gemini-playwright-mcp.js" // CHANGE: add Gemini CLI entrypoint configuration // WHY: enable Gemini CLI in Docker with automated auth, trust settings and MCP @@ -199,82 +201,6 @@ if [[ -d /etc/sudoers.d ]]; then chmod 0440 /etc/sudoers.d/gemini-agent fi` -const renderGeminiMcpPlaywrightConfig = (): string => - String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings -docker_git_sync_gemini_playwright_mcp() { - local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" - local browser_network="container:$browser_project" - GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" -const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } -} else { - delete nextServers.playwright -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_gemini_playwright_mcp` - -const renderGeminiMcpAndroidConfig = (): string => - String.raw`# Gemini CLI: keep Android MCP config in sync with container settings -docker_git_sync_gemini_android_mcp() { - local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" - GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" -const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_ANDROID_ENABLE === "1") { - nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } -} else { - delete nextServers.android -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_gemini_android_mcp` const renderGeminiProfileSetup = (config: TemplateConfig): string => String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" diff --git a/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts new file mode 100644 index 00000000..0dc9faac --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts @@ -0,0 +1,43 @@ +// CHANGE: extract the Grok Android MCP config sync into its own module +// WHY: issue-436 wires mcp-android "the same way" Playwright MCP works; keeping the +// render helper in a dedicated file keeps grok.ts under the max-lines lint budget +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/mobile-next/mobile-mcp +export const renderGrokMcpAndroidConfig = (): string => + String.raw`# Grok CLI: keep Android MCP config in sync with container settings +docker_git_sync_grok_android_mcp() { + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_grok_android_mcp` diff --git a/packages/container/src/core/templates-entrypoint/grok.ts b/packages/container/src/core/templates-entrypoint/grok.ts index 336f172f..3e5296fd 100644 --- a/packages/container/src/core/templates-entrypoint/grok.ts +++ b/packages/container/src/core/templates-entrypoint/grok.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" +import { renderGrokMcpAndroidConfig } from "./grok-android-mcp.js" // CHANGE: add Grok CLI entrypoint configuration // WHY: issue #304 requires Grok auth, Playwright MCP and unrestricted agent permissions @@ -224,44 +225,6 @@ NODE docker_git_sync_grok_playwright_mcp` -const renderGrokMcpAndroidConfig = (): string => - String.raw`# Grok CLI: keep Android MCP config in sync with container settings -docker_git_sync_grok_android_mcp() { - local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" - GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" -const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_ANDROID_ENABLE === "1") { - nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } -} else { - delete nextServers.android -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_grok_android_mcp` - const renderGrokSudoConfig = (config: TemplateConfig): string => `# Grok CLI: allow passwordless sudo for agent tasks # Risk rationale: Grok runs inside an isolated per-project container. The sshUser diff --git a/packages/container/src/core/templates/docker-compose-android.ts b/packages/container/src/core/templates/docker-compose-android.ts new file mode 100644 index 00000000..4269a244 --- /dev/null +++ b/packages/container/src/core/templates/docker-compose-android.ts @@ -0,0 +1,65 @@ +import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" + +// CHANGE: render an Android emulator sidecar service mirroring the Playwright MCP wiring +// WHY: issue-436 asks to connect mcp-android "the same way" Playwright MCP works, exposing +// a docker-android emulator as a service reachable over ADB for the mobile-mcp server. +// Extracted into its own module so docker-compose.ts stays under the max-lines budget. +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/budtmo/docker-android +// PURITY: CORE +// INVARIANT: only emitted when config.enableMcpAndroid === true; image/ports are env-overridable +export type AndroidFragments = { + readonly maybeAndroidEnv: string + readonly maybeAndroidService: string + readonly maybeAndroidVolume: string +} + +const defaultAndroidEmulatorImage = "budtmo/docker-android:emulator_14.0" + +export const buildAndroidFragments = ( + config: TemplateConfig, + resourceLimitsBlock: string +): AndroidFragments => { + if (config.enableMcpAndroid !== true) { + return { + maybeAndroidEnv: "", + maybeAndroidService: "", + maybeAndroidVolume: "" + } + } + + const androidContainerName = `${config.containerName}-android` + const androidVolumeName = `${config.volumeName}-android` + const androidImageRef = `\${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-${defaultAndroidEmulatorImage}}` + const networkName = resolveComposeNetworkName(config) + + const maybeAndroidEnv = + ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` + + const maybeAndroidService = ` + ${config.serviceName}-android: + image: "${androidImageRef}" + container_name: ${androidContainerName} + privileged: true + environment: + EMULATOR_DEVICE: "\${DOCKER_GIT_ANDROID_DEVICE:-Samsung Galaxy S10}" + WEB_VNC: "\${DOCKER_GIT_ANDROID_WEB_VNC:-true}" + EMULATOR_HEADLESS: "\${DOCKER_GIT_ANDROID_HEADLESS:-true}" + devices: + - /dev/kvm + ports: + - "\${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555" + - "\${DOCKER_GIT_ANDROID_NOVNC_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_NOVNC_PORT:-6080}:6080" +${resourceLimitsBlock} volumes: + - ${androidVolumeName}:/root/.android + networks: + - ${networkName} +` + + return { + maybeAndroidEnv, + maybeAndroidService, + maybeAndroidVolume: ` ${androidVolumeName}:` + } +} diff --git a/packages/container/src/core/templates/docker-compose-playwright.ts b/packages/container/src/core/templates/docker-compose-playwright.ts new file mode 100644 index 00000000..abe6bc78 --- /dev/null +++ b/packages/container/src/core/templates/docker-compose-playwright.ts @@ -0,0 +1,61 @@ +import type { TemplateConfig } from "../domain.js" +import type { ResolvedComposeResourceLimits } from "../resource-limits.js" +import type { DockerComposeRenderOptions } from "./docker-compose.js" + +// CHANGE: house the Playwright MCP sidecar fragment builder in its own module +// WHY: docker-compose.ts hosts the optional Android sidecar (issue-436) too; moving both +// optional-sidecar builders into sibling modules keeps docker-compose.ts under the +// max-lines lint budget while grouping the parallel Playwright/Android wiring together +// REF: issue-436 +export type PlaywrightFragments = { + readonly maybeDependsOn: string + readonly maybeDockerSocketMount: string + readonly maybePlaywrightEnv: string + readonly maybeBrowserVolume: string +} + +const renderBrowserLimitEnv = ( + key: string, + value: number | string | undefined +): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` + +const renderOptionalDockerSocketMount = ( + shouldEnableLocalDockerSocket: boolean +): string => + shouldEnableLocalDockerSocket + ? ` - /var/run/docker.sock:/var/run/docker.sock` + : "" + +export const buildPlaywrightFragments = ( + config: TemplateConfig, + resourceLimits: ResolvedComposeResourceLimits | undefined, + options: DockerComposeRenderOptions +): PlaywrightFragments => { + if (!config.enableMcpPlaywright) { + return { + maybeDependsOn: "", + maybeDockerSocketMount: "", + maybePlaywrightEnv: "", + maybeBrowserVolume: "" + } + } + + const browserContainerName = `${config.containerName}-browser` + const browserVolumeName = `${config.volumeName}-browser` + const browserImageName = `${browserContainerName}:docker-git-browser` + + return { + maybeDependsOn: "", + maybeDockerSocketMount: renderOptionalDockerSocketMount( + options.enableLocalDockerSocket + ), + maybePlaywrightEnv: + ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ + renderBrowserLimitEnv( + "DOCKER_GIT_BROWSER_CPU_LIMIT", + resourceLimits?.cpuLimit + ) + }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, + maybeBrowserVolume: ` ${browserVolumeName}:` + } +} diff --git a/packages/container/src/core/templates/docker-compose.ts b/packages/container/src/core/templates/docker-compose.ts index fe8408bf..7fa1f340 100644 --- a/packages/container/src/core/templates/docker-compose.ts +++ b/packages/container/src/core/templates/docker-compose.ts @@ -7,6 +7,8 @@ import { type TemplateConfig } from "../domain.js" import type { ResolvedComposeResourceLimits } from "../resource-limits.js" +import { buildAndroidFragments } from "./docker-compose-android.js" +import { buildPlaywrightFragments } from "./docker-compose-playwright.js" type ComposeFragments = { readonly networkMode: TemplateConfig["dockerNetworkMode"] @@ -30,21 +32,6 @@ type ComposeFragments = { readonly forkRepoUrl: string } -type PlaywrightFragments = Pick< - ComposeFragments, - | "maybeDependsOn" - | "maybeDockerSocketMount" - | "maybePlaywrightEnv" - | "maybeBrowserVolume" -> - -type AndroidFragments = Pick< - ComposeFragments, - | "maybeAndroidEnv" - | "maybeAndroidService" - | "maybeAndroidVolume" -> - type AuthEnvFragments = Pick< ComposeFragments, | "maybeGitTokenLabelEnv" @@ -122,13 +109,6 @@ const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/ const renderYamlSingleQuoted = (value: string): string => `'${value.replaceAll("'", "''")}'` -const renderOptionalDockerSocketMount = ( - shouldEnableLocalDockerSocket: boolean -): string => - shouldEnableLocalDockerSocket - ? ` - /var/run/docker.sock:/var/run/docker.sock` - : "" - const renderEnvFiles = (config: TemplateConfig): string => ` env_file:\n - ${renderYamlSingleQuoted(config.envGlobalPath)}\n - ${ renderYamlSingleQuoted( @@ -161,102 +141,6 @@ const buildAgentEnvFragments = (config: TemplateConfig): AgentEnvFragments => ({ maybeAgentAutoEnv: renderAgentAutoEnv(config.agentAuto) }) -const renderBrowserLimitEnv = ( - key: string, - value: number | string | undefined -): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` - -const buildPlaywrightFragments = ( - config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined, - options: DockerComposeRenderOptions -): PlaywrightFragments => { - if (!config.enableMcpPlaywright) { - return { - maybeDependsOn: "", - maybeDockerSocketMount: "", - maybePlaywrightEnv: "", - maybeBrowserVolume: "" - } - } - - const browserContainerName = `${config.containerName}-browser` - const browserVolumeName = `${config.volumeName}-browser` - const browserImageName = `${browserContainerName}:docker-git-browser` - - return { - maybeDependsOn: "", - maybeDockerSocketMount: renderOptionalDockerSocketMount( - options.enableLocalDockerSocket - ), - maybePlaywrightEnv: - ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ - renderBrowserLimitEnv( - "DOCKER_GIT_BROWSER_CPU_LIMIT", - resourceLimits?.cpuLimit - ) - }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, - maybeBrowserVolume: ` ${browserVolumeName}:` - } -} - -// CHANGE: render an Android emulator sidecar service mirroring the Playwright MCP wiring -// WHY: issue-436 asks to connect mcp-android "the same way" Playwright MCP works, exposing -// a docker-android emulator as a service reachable over ADB for the mobile-mcp server -// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" -// REF: issue-436 -// SOURCE: https://github.com/budtmo/docker-android -// PURITY: CORE -// INVARIANT: only emitted when config.enableMcpAndroid === true; image/ports are env-overridable -const defaultAndroidEmulatorImage = "budtmo/docker-android:emulator_14.0" - -const buildAndroidFragments = ( - config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined -): AndroidFragments => { - if (config.enableMcpAndroid !== true) { - return { - maybeAndroidEnv: "", - maybeAndroidService: "", - maybeAndroidVolume: "" - } - } - - const androidContainerName = `${config.containerName}-android` - const androidVolumeName = `${config.volumeName}-android` - const androidImageRef = `\${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-${defaultAndroidEmulatorImage}}` - const networkName = resolveComposeNetworkName(config) - - const maybeAndroidEnv = - ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` - - const maybeAndroidService = ` - ${config.serviceName}-android: - image: "${androidImageRef}" - container_name: ${androidContainerName} - privileged: true - environment: - EMULATOR_DEVICE: "\${DOCKER_GIT_ANDROID_DEVICE:-Samsung Galaxy S10}" - WEB_VNC: "\${DOCKER_GIT_ANDROID_WEB_VNC:-true}" - EMULATOR_HEADLESS: "\${DOCKER_GIT_ANDROID_HEADLESS:-true}" - devices: - - /dev/kvm - ports: - - "\${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555" - - "\${DOCKER_GIT_ANDROID_NOVNC_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_NOVNC_PORT:-6080}:6080" -${renderResourceLimits(resourceLimits)} volumes: - - ${androidVolumeName}:/root/.android - networks: - - ${networkName} -` - - return { - maybeAndroidEnv, - maybeAndroidService, - maybeAndroidVolume: ` ${androidVolumeName}:` - } -} - const isResolvedComposeResourceLimits = ( value: ResolvedComposeResourceLimits | ComposeResourceLimits ): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value @@ -292,7 +176,10 @@ const buildComposeFragments = ( resourceLimits.playwright, options ) - const android = buildAndroidFragments(config, resourceLimits.playwright) + const android = buildAndroidFragments( + config, + renderResourceLimits(resourceLimits.playwright) + ) return { networkMode, From 85c40cbe6b44e13e65310aea866fdb2198fa53e8 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 17:14:06 +0000 Subject: [PATCH 09/11] Revert "Initial commit with task details" This reverts commit 1113f2720c1c6030a3099b77ed6bbaafd193dc51. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index d787956e..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-23T15:38:46.439Z for PR creation at branch issue-436-7a79950cbb5b for issue https://github.com/ProverCoderAI/docker-git/issues/436 \ No newline at end of file From b221cd1d9bbd976c21ec13090d6f44626277b012 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 23 Jun 2026 20:01:09 +0000 Subject: [PATCH 10/11] feat(container): add first-party Android MCP module --- .changeset/mcp-android-integration.md | 2 +- .github/workflows/check.yml | 20 + README.md | 2 +- crates/android-connection/.gitignore | 1 + crates/android-connection/Cargo.lock | 249 +++++++ crates/android-connection/Cargo.toml | 24 + crates/android-connection/README.md | 10 + .../src/bin/android-connection.rs | 54 ++ crates/android-connection/src/lib.rs | 253 +++++++ crates/android-connection/src/main.rs | 104 +++ crates/android-connection/src/mcp.rs | 635 ++++++++++++++++++ packages/app/src/docker-git/cli/usage.ts | 4 +- .../app/src/docker-git/menu-create-labels.ts | 5 +- .../src/core/templates-entrypoint/claude.ts | 19 +- .../src/core/templates-entrypoint/codex.ts | 22 +- .../gemini-android-mcp.ts | 22 +- .../templates-entrypoint/grok-android-mcp.ts | 22 +- .../core/templates/docker-compose-android.ts | 6 +- .../src/core/templates/dockerfile.ts | 16 +- .../container/tests/core/templates.test.ts | 14 +- .../src/shell/android-connection-source.ts | 149 ++++ packages/lib/src/shell/files.ts | 21 +- packages/lib/src/usecases/mcp-android.ts | 4 +- packages/lib/tests/usecases/apply.test.ts | 9 + .../lib/tests/usecases/mcp-android.test.ts | 11 + 25 files changed, 1627 insertions(+), 51 deletions(-) create mode 100644 crates/android-connection/.gitignore create mode 100644 crates/android-connection/Cargo.lock create mode 100644 crates/android-connection/Cargo.toml create mode 100644 crates/android-connection/README.md create mode 100644 crates/android-connection/src/bin/android-connection.rs create mode 100644 crates/android-connection/src/lib.rs create mode 100644 crates/android-connection/src/main.rs create mode 100644 crates/android-connection/src/mcp.rs create mode 100644 packages/lib/src/shell/android-connection-source.ts diff --git a/.changeset/mcp-android-integration.md b/.changeset/mcp-android-integration.md index bf1502f1..8438fcd9 100644 --- a/.changeset/mcp-android-integration.md +++ b/.changeset/mcp-android-integration.md @@ -5,7 +5,7 @@ Add Android MCP integration alongside the existing Playwright MCP support (issue #436). Projects can now opt into a nested Android emulator sidecar driven by the -`@mobilenext/mobile-mcp` server, mirroring how Playwright MCP works. Enable it +first-party Rust `android-connection` MCP server, mirroring how Playwright MCP works. Enable it with the new `--mcp-android` / `--no-mcp-android` create flags, the `mcp-android` subcommand, the interactive create-flow prompt, or the `enableMcpAndroid` field on the web/API create-project request. When enabled, the generated diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 35247746..0312df2a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,6 +39,26 @@ jobs: - name: Build (api) run: bun run --cwd packages/api build + rust-android-connection: + name: Rust Android connection + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Rust version + run: | + rustc --version + cargo --version + - name: Format + working-directory: crates/android-connection + run: cargo fmt --check + - name: Test + working-directory: crates/android-connection + run: cargo test --locked + - name: Clippy + working-directory: crates/android-connection + run: cargo clippy --locked --all-targets -- -D warnings + dist-deps-prune: name: Dist deps prune runs-on: ubuntu-latest diff --git a/README.md b/README.md index e232e522..bca9d692 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ bun run docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 - `--force` пересоздаёт окружение и удаляет volumes проекта. - `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации. -- `--mcp-android` включает Android MCP (`@mobilenext/mobile-mcp`) и вложенный sidecar с Android-эмулятором (`docker-android`) для мобильной автоматизации. +- `--mcp-android` включает first-party Android MCP (`android-connection`) и вложенный sidecar с Android-эмулятором (`docker-android`) для мобильной автоматизации. Автоматический запуск агента: diff --git a/crates/android-connection/.gitignore b/crates/android-connection/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/crates/android-connection/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/crates/android-connection/Cargo.lock b/crates/android-connection/Cargo.lock new file mode 100644 index 00000000..038d1da3 --- /dev/null +++ b/crates/android-connection/Cargo.lock @@ -0,0 +1,249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "docker-git-android-connection" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/android-connection/Cargo.toml b/crates/android-connection/Cargo.toml new file mode 100644 index 00000000..87c88919 --- /dev/null +++ b/crates/android-connection/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "docker-git-android-connection" +version = "0.1.0" +edition = "2021" +description = "First-party Android MCP and lifecycle module for docker-git" +license = "MIT" +repository = "https://github.com/ProverCoderAI/docker-git" + +[lib] +name = "docker_git_android_connection" +path = "src/lib.rs" + +[[bin]] +name = "docker-git-android-connection" +path = "src/main.rs" + +[[bin]] +name = "android-connection" +path = "src/bin/android-connection.rs" + +[dependencies] +clap = { version = "4.5.53", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" diff --git a/crates/android-connection/README.md b/crates/android-connection/README.md new file mode 100644 index 00000000..17b14d76 --- /dev/null +++ b/crates/android-connection/README.md @@ -0,0 +1,10 @@ +# docker-git Android connection + +First-party Android MCP module for docker-git. + +The crate provides two binaries: + +- `android-connection`: MCP stdio server used by Codex, Claude, Gemini, and Grok. +- `docker-git-android-connection`: lifecycle CLI for deterministic Android runtime naming and Docker command construction. + +The core module keeps deterministic naming, endpoint validation, and tool specifications pure. Shell effects are isolated in the binaries and MCP tool handlers. diff --git a/crates/android-connection/src/bin/android-connection.rs b/crates/android-connection/src/bin/android-connection.rs new file mode 100644 index 00000000..8366df39 --- /dev/null +++ b/crates/android-connection/src/bin/android-connection.rs @@ -0,0 +1,54 @@ +use clap::Parser; +use docker_git_android_connection::mcp::{run_stdio, McpState}; +use docker_git_android_connection::{ + android_spec, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, +}; +use std::io::{self, BufReader}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command(version, about = "Android MCP stdio server for docker-git")] +struct Cli { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long, default_value = ".")] + workspace: PathBuf, + #[arg(long)] + allow_install: bool, + #[arg(long)] + no_adb_probe: bool, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let spec = android_spec(&cli.project, &cli.network, &cli.endpoint, &cli.image)?; + let state = McpState { + spec, + workspace: cli.workspace, + adb_probe: !cli.no_adb_probe, + allow_install: cli.allow_install, + }; + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + let mut writer = stdout.lock(); + run_stdio(&mut reader, &mut writer, state)?; + Ok(()) +} diff --git a/crates/android-connection/src/lib.rs b/crates/android-connection/src/lib.rs new file mode 100644 index 00000000..54c82f6b --- /dev/null +++ b/crates/android-connection/src/lib.rs @@ -0,0 +1,253 @@ +pub mod mcp; + +use serde::Serialize; + +pub const SERVER_NAME: &str = "android-connection"; +pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; +pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; +pub const DEFAULT_PROJECT_ID: &str = "docker-git"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EndpointError { + pub value: String, +} + +impl std::fmt::Display for EndpointError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "invalid ADB endpoint {:?}; allowed characters are ASCII letters, digits, '.', '-', '_' and ':'", + self.value + ) + } +} + +impl std::error::Error for EndpointError {} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct AndroidSpec { + pub project_id: String, + pub project_container_name: String, + pub android_container_name: String, + pub android_volume_name: String, + pub docker_network: String, + pub adb_endpoint: String, + pub image: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct McpToolSpec { + pub name: &'static str, + pub description: &'static str, +} + +// CHANGE: normalize externally supplied project ids into Docker-safe names +// WHY: Android sidecar names are pure functions of the project id, so MCP clients and lifecycle CLI agree +// QUOTE(TZ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall s: normalize(s) in [a-z0-9-]+ and normalize(s) != "" +// PURITY: CORE +// INVARIANT: output is non-empty, lowercase, and contains only Docker-name-safe characters +// COMPLEXITY: O(n)/O(n) +pub fn normalize_project_id(raw: &str) -> String { + let mut normalized = String::new(); + let mut previous_dash = false; + + for byte in raw.bytes() { + let next = match byte { + b'a'..=b'z' | b'0'..=b'9' => Some(byte as char), + b'A'..=b'Z' => Some(byte.to_ascii_lowercase() as char), + _ => { + if normalized.is_empty() || previous_dash { + None + } else { + Some('-') + } + } + }; + + if let Some(character) = next { + previous_dash = character == '-'; + normalized.push(character); + } + } + + while normalized.ends_with('-') { + normalized.pop(); + } + + if normalized.is_empty() { + DEFAULT_PROJECT_ID.to_string() + } else { + normalized + } +} + +pub fn android_container_name(project_id: &str) -> String { + format!("{}-android", normalize_project_id(project_id)) +} + +pub fn android_volume_name(project_id: &str) -> String { + format!("{}-home-android", normalize_project_id(project_id)) +} + +pub fn is_safe_adb_endpoint(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && value.contains(':') + && value.bytes().all(|byte| { + matches!( + byte, + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b':' + ) + }) +} + +pub fn validate_adb_endpoint(value: &str) -> Result { + if is_safe_adb_endpoint(value) { + Ok(value.to_string()) + } else { + Err(EndpointError { + value: value.to_string(), + }) + } +} + +pub fn android_spec( + project_id: &str, + docker_network: &str, + adb_endpoint: &str, + image: &str, +) -> Result { + let normalized = normalize_project_id(project_id); + Ok(AndroidSpec { + project_id: normalized.clone(), + project_container_name: normalized.clone(), + android_container_name: android_container_name(&normalized), + android_volume_name: android_volume_name(&normalized), + docker_network: docker_network.to_string(), + adb_endpoint: validate_adb_endpoint(adb_endpoint)?, + image: image.to_string(), + }) +} + +pub fn android_tools() -> Vec { + vec![ + McpToolSpec { + name: "android_status", + description: "Return the configured Android runtime and optional ADB status.", + }, + McpToolSpec { + name: "android_devices", + description: "List Android devices visible to adb.", + }, + McpToolSpec { + name: "android_screenshot", + description: "Capture a PNG screenshot into the workspace.", + }, + McpToolSpec { + name: "android_tap", + description: "Tap screen coordinates.", + }, + McpToolSpec { + name: "android_swipe", + description: "Swipe between screen coordinates.", + }, + McpToolSpec { + name: "android_type_text", + description: "Type text into the active Android input field.", + }, + McpToolSpec { + name: "android_press_key", + description: "Send an Android keycode.", + }, + McpToolSpec { + name: "android_launch_app", + description: "Launch an installed Android package.", + }, + McpToolSpec { + name: "android_open_url", + description: "Open a URL through Android intent handling.", + }, + McpToolSpec { + name: "android_logcat", + description: "Read recent logcat output.", + }, + McpToolSpec { + name: "android_install_apk", + description: "Install an APK from the workspace when explicitly enabled.", + }, + ] +} + +pub fn docker_run_args(spec: &AndroidSpec) -> Vec { + vec![ + "run".to_string(), + "--detach".to_string(), + "--name".to_string(), + spec.android_container_name.clone(), + "--privileged".to_string(), + "--network".to_string(), + spec.docker_network.clone(), + "--env".to_string(), + "EMULATOR_HEADLESS=true".to_string(), + "--env".to_string(), + "WEB_VNC=true".to_string(), + "--volume".to_string(), + format!("{}:/root/.android", spec.android_volume_name), + spec.image.clone(), + ] +} + +pub fn docker_stop_args(spec: &AndroidSpec) -> Vec { + vec![ + "rm".to_string(), + "--force".to_string(), + spec.android_container_name.clone(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_project_id_to_docker_safe_name() { + assert_eq!( + normalize_project_id("Org/Repo:Feature_X"), + "org-repo-feature-x" + ); + assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); + } + + #[test] + fn rejects_shell_fragments_in_adb_endpoint() { + assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); + assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); + assert!(validate_adb_endpoint("$(whoami):5555").is_err()); + } + + #[test] + fn builds_deterministic_android_spec() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + + assert_eq!(spec.project_container_name, "dg-test"); + assert_eq!(spec.android_container_name, "dg-test-android"); + assert_eq!(spec.android_volume_name, "dg-test-home-android"); + } + + #[test] + fn advertises_android_mcp_tools() { + let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); + assert!(names.contains(&"android_status")); + assert!(names.contains(&"android_tap")); + assert!(names.contains(&"android_install_apk")); + } +} diff --git a/crates/android-connection/src/main.rs b/crates/android-connection/src/main.rs new file mode 100644 index 00000000..2dc0fa43 --- /dev/null +++ b/crates/android-connection/src/main.rs @@ -0,0 +1,104 @@ +use clap::{Args, Parser, Subcommand}; +use docker_git_android_connection::{ + android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, + DEFAULT_PROJECT_ID, +}; +use serde_json::json; +use std::process::{Command, ExitCode}; + +#[derive(Parser, Debug)] +#[command(version, about = "docker-git Android runtime lifecycle CLI")] +struct Cli { + #[command(subcommand)] + command: LifecycleCommand, +} + +#[derive(Subcommand, Debug)] +enum LifecycleCommand { + Start(LifecycleArgs), + Status(LifecycleArgs), + Stop(LifecycleArgs), +} + +#[derive(Args, Clone, Debug)] +struct LifecycleArgs { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long)] + dry_run: bool, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + match cli.command { + LifecycleCommand::Start(args) => start(args), + LifecycleCommand::Status(args) => status(args), + LifecycleCommand::Stop(args) => stop(args), + } +} + +fn start(args: LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_run_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); + } + + run_docker(&docker_args) +} + +fn status(args: LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + println!("{}", serde_json::to_string_pretty(&spec)?); + Ok(()) +} + +fn stop(args: LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_stop_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); + } + + run_docker(&docker_args) +} + +fn run_docker(args: &[String]) -> Result<(), Box> { + let output = Command::new("docker").args(args).output()?; + if output.status.success() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + return Ok(()); + } + + Err(format!( + "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()) +} diff --git a/crates/android-connection/src/mcp.rs b/crates/android-connection/src/mcp.rs new file mode 100644 index 00000000..d4a6dec3 --- /dev/null +++ b/crates/android-connection/src/mcp.rs @@ -0,0 +1,635 @@ +use crate::{android_tools, AndroidSpec, SERVER_NAME}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; + +#[derive(Clone, Debug)] +pub struct McpState { + pub spec: AndroidSpec, + pub workspace: PathBuf, + pub adb_probe: bool, + pub allow_install: bool, +} + +#[derive(Debug)] +enum McpToolError { + MissingArgument(&'static str), + InvalidArgument(String), + AdbProbeDisabled, + CommandFailed(String), + Io(String), +} + +impl std::fmt::Display for McpToolError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingArgument(name) => write!(formatter, "missing required argument: {name}"), + Self::InvalidArgument(message) => write!(formatter, "invalid argument: {message}"), + Self::AdbProbeDisabled => write!(formatter, "ADB probing is disabled for this server"), + Self::CommandFailed(message) => write!(formatter, "{message}"), + Self::Io(message) => write!(formatter, "{message}"), + } + } +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Option, + method: String, + params: Option, +} + +pub fn run_stdio(reader: &mut R, writer: &mut W, state: McpState) -> io::Result<()> +where + R: BufRead, + W: Write, +{ + while let Some(raw) = read_next_message(reader)? { + let response = match serde_json::from_str::(&raw) { + Ok(request) => handle_request(&request, &state), + Err(error) => Some(json_rpc_error( + Value::Null, + -32700, + &format!("invalid JSON-RPC request: {error}"), + )), + }; + + if let Some(value) = response { + write_json_message(writer, &value)?; + } + } + + Ok(()) +} + +fn read_next_message(reader: &mut R) -> io::Result> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(None); + } + + let first_line = line.trim_end_matches(['\r', '\n']); + if first_line.is_empty() { + continue; + } + + if let Some(length) = parse_content_length(first_line)? { + read_headers(reader)?; + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload)?; + return String::from_utf8(payload) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + + return Ok(Some(first_line.to_string())); + } +} + +fn parse_content_length(header: &str) -> io::Result> { + let lowercase = header.to_ascii_lowercase(); + if !lowercase.starts_with("content-length:") { + return Ok(None); + } + + let raw_length = header + .split_once(':') + .map(|(_, value)| value.trim()) + .unwrap_or_default(); + raw_length + .parse::() + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn read_headers(reader: &mut R) -> io::Result<()> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(()); + } + if line.trim().is_empty() { + return Ok(()); + } + } +} + +fn write_json_message(writer: &mut W, value: &Value) -> io::Result<()> { + let body = serde_json::to_string(value) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?; + writer.flush() +} + +fn handle_request(request: &JsonRpcRequest, state: &McpState) -> Option { + if request.id.is_none() && request.method.starts_with("notifications/") { + return None; + } + + let id = request.id.clone().unwrap_or(Value::Null); + let response = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION") + } + }), + "tools/list" => json!({ "tools": render_tools() }), + "tools/call" => return Some(handle_tools_call(id, request.params.as_ref(), state)), + method => { + return Some(json_rpc_error( + id, + -32601, + &format!("method not found: {method}"), + )) + } + }; + + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": response + })) +} + +fn json_rpc_error(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +fn render_tools() -> Value { + Value::Array( + android_tools() + .into_iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": { + "type": "object", + "additionalProperties": true + } + }) + }) + .collect(), + ) +} + +fn handle_tools_call(id: Value, params: Option<&Value>, state: &McpState) -> Value { + let result = call_tool_from_params(params, state); + let (text, is_error) = match result { + Ok(text) => (text, false), + Err(error) => (error.to_string(), true), + }; + + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [ + { + "type": "text", + "text": text + } + ], + "isError": is_error + } + }) +} + +fn call_tool_from_params(params: Option<&Value>, state: &McpState) -> Result { + let params = + params.ok_or_else(|| McpToolError::InvalidArgument("missing params".to_string()))?; + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| McpToolError::InvalidArgument("missing tool name".to_string()))?; + let arguments = params.get("arguments").unwrap_or(&Value::Null); + + match name { + "android_status" => android_status(state), + "android_devices" => android_devices(state), + "android_screenshot" => android_screenshot(state, arguments), + "android_tap" => android_tap(state, arguments), + "android_swipe" => android_swipe(state, arguments), + "android_type_text" => android_type_text(state, arguments), + "android_press_key" => android_press_key(state, arguments), + "android_launch_app" => android_launch_app(state, arguments), + "android_open_url" => android_open_url(state, arguments), + "android_logcat" => android_logcat(state, arguments), + "android_install_apk" => android_install_apk(state, arguments), + unknown => Err(McpToolError::InvalidArgument(format!( + "unknown Android MCP tool: {unknown}" + ))), + } +} + +fn android_status(state: &McpState) -> Result { + if !state.adb_probe { + return serde_json::to_string_pretty(&json!({ + "server": SERVER_NAME, + "adbProbe": false, + "spec": state.spec + })) + .map_err(|error| McpToolError::Io(error.to_string())); + } + + match run_adb(state, &["devices".to_string()]) { + Ok(output) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\n\n{}", + state.spec.android_container_name, state.spec.adb_endpoint, output + )), + Err(error) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\nADB status error: {}", + state.spec.android_container_name, state.spec.adb_endpoint, error + )), + } +} + +fn android_devices(state: &McpState) -> Result { + run_adb(state, &["devices".to_string()]) +} + +fn android_tap(state: &McpState, arguments: &Value) -> Result { + let x = integer_argument(arguments, "x")?; + let y = integer_argument(arguments, "y")?; + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "tap".to_string(), + x.to_string(), + y.to_string(), + ], + ) +} + +fn android_swipe(state: &McpState, arguments: &Value) -> Result { + let start_x = integer_argument(arguments, "startX")?; + let start_y = integer_argument(arguments, "startY")?; + let end_x = integer_argument(arguments, "endX")?; + let end_y = integer_argument(arguments, "endY")?; + let duration_ms = optional_integer_argument(arguments, "durationMs")?.unwrap_or(300); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "swipe".to_string(), + start_x.to_string(), + start_y.to_string(), + end_x.to_string(), + end_y.to_string(), + duration_ms.to_string(), + ], + ) +} + +fn android_type_text(state: &McpState, arguments: &Value) -> Result { + let text = string_argument(arguments, "text")?.replace(' ', "%s"); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "text".to_string(), + text, + ], + ) +} + +fn android_press_key(state: &McpState, arguments: &Value) -> Result { + let keycode = string_argument(arguments, "keycode")?; + if !keycode + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) + { + return Err(McpToolError::InvalidArgument( + "keycode may contain only ASCII letters, digits, and '_'".to_string(), + )); + } + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "keyevent".to_string(), + keycode, + ], + ) +} + +fn android_launch_app(state: &McpState, arguments: &Value) -> Result { + let package_name = string_argument(arguments, "package")?; + let activity = optional_string_argument(arguments, "activity")?; + match activity { + Some(activity) if !activity.is_empty() => run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + format!("{package_name}/{activity}"), + ], + ), + _ => run_adb( + state, + &[ + "shell".to_string(), + "monkey".to_string(), + "-p".to_string(), + package_name, + "-c".to_string(), + "android.intent.category.LAUNCHER".to_string(), + "1".to_string(), + ], + ), + } +} + +fn android_open_url(state: &McpState, arguments: &Value) -> Result { + let url = string_argument(arguments, "url")?; + run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + url, + ], + ) +} + +fn android_logcat(state: &McpState, arguments: &Value) -> Result { + let lines = optional_integer_argument(arguments, "lines")? + .unwrap_or(200) + .clamp(1, 1000); + run_adb( + state, + &[ + "logcat".to_string(), + "-d".to_string(), + "-t".to_string(), + lines.to_string(), + ], + ) +} + +fn android_screenshot(state: &McpState, arguments: &Value) -> Result { + let output_path = optional_string_argument(arguments, "path")? + .unwrap_or_else(|| "android-screenshot.png".to_string()); + let target_path = workspace_path(&state.workspace, &output_path)?; + let output = run_adb_raw( + state, + &[ + "exec-out".to_string(), + "screencap".to_string(), + "-p".to_string(), + ], + )?; + if !output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb screenshot", + output, + ))); + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| McpToolError::Io(error.to_string()))?; + } + fs::write(&target_path, output.stdout).map_err(|error| McpToolError::Io(error.to_string()))?; + Ok(format!("screenshot written to {}", target_path.display())) +} + +fn android_install_apk(state: &McpState, arguments: &Value) -> Result { + if !state.allow_install { + return Err(McpToolError::InvalidArgument( + "APK installation requires --allow-install".to_string(), + )); + } + let apk_path = string_argument(arguments, "path")?; + let target_path = workspace_path(&state.workspace, &apk_path)?; + run_adb( + state, + &["install".to_string(), target_path.display().to_string()], + ) +} + +fn run_adb(state: &McpState, args: &[String]) -> Result { + let output = run_adb_raw(state, args)?; + output_to_text("adb", output) +} + +fn run_adb_raw(state: &McpState, args: &[String]) -> Result { + if !state.adb_probe { + return Err(McpToolError::AdbProbeDisabled); + } + + let connect_output = Command::new("adb") + .arg("connect") + .arg(&state.spec.adb_endpoint) + .output() + .map_err(|error| { + McpToolError::CommandFailed(format!("failed to execute adb connect: {error}")) + })?; + if !connect_output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb connect", + connect_output, + ))); + } + + Command::new("adb") + .args(args) + .output() + .map_err(|error| McpToolError::CommandFailed(format!("failed to execute adb: {error}"))) +} + +fn output_to_text(label: &str, output: Output) -> Result { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Ok(match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => format!("{label} completed successfully"), + (false, true) => stdout, + (true, false) => stderr, + (false, false) => format!("{stdout}\n{stderr}"), + }); + } + + Err(McpToolError::CommandFailed(command_failure(label, output))) +} + +fn command_failure(label: &str, output: Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!( + "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout.trim(), + stderr.trim() + ) +} + +fn integer_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_i64) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_integer_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + match arguments.get(name) { + Some(value) => value + .as_i64() + .map(Some) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be an integer"))), + None => Ok(None), + } +} + +fn string_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_string_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + match arguments.get(name) { + Some(value) => value + .as_str() + .map(|text| Some(text.to_string())) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be a string"))), + None => Ok(None), + } +} + +fn workspace_path(workspace: &Path, value: &str) -> Result { + let candidate = PathBuf::from(value); + if value.is_empty() + || candidate.is_absolute() + || candidate + .components() + .any(|component| component == Component::ParentDir) + { + return Err(McpToolError::InvalidArgument( + "path must be relative, non-empty, and must not contain '..'".to_string(), + )); + } + + Ok(workspace.join(candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{android_spec, DEFAULT_ANDROID_IMAGE}; + use std::io::Cursor; + + fn test_state() -> McpState { + McpState { + spec: android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid android spec"), + workspace: PathBuf::from("/workspace"), + adb_probe: false, + allow_install: false, + } + } + + fn frame(value: Value) -> String { + let payload = serde_json::to_string(&value).expect("serializable request"); + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) + } + + #[test] + fn serves_initialize_and_tools_list_over_framed_stdio() { + let input = format!( + "{}{}", + frame(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })), + frame(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + ); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, test_state()).expect("stdio server succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains(SERVER_NAME)); + assert!(output_text.contains("android_status")); + assert!(output_text.contains("android_tap")); + } + + #[test] + fn reports_status_without_adb_when_probe_is_disabled() { + let input = frame(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "android_status", + "arguments": {} + } + })); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, test_state()).expect("status succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains("\"isError\":false")); + assert!(output_text.contains("adbProbe")); + assert!(output_text.contains("false")); + assert!(output_text.contains("dg-test-android")); + } + + #[test] + fn rejects_workspace_paths_outside_workspace() { + let workspace = PathBuf::from("/workspace"); + + assert!(workspace_path(&workspace, "screenshots/current.png").is_ok()); + assert!(workspace_path(&workspace, "/tmp/outside.png").is_err()); + assert!(workspace_path(&workspace, "../outside.png").is_err()); + assert!(workspace_path(&workspace, "screenshots/../outside.png").is_err()); + } +} diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 9c0dceaf..4d7e0477 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -28,7 +28,7 @@ Commands: open Open an existing docker-git project by selector, URL, or path apply Apply docker-git config to an existing project/container (current dir by default) mcp-playwright Enable Playwright MCP + nested Chromium browser for an existing project dir - mcp-android Enable Android MCP (mobile-mcp) + nested Android emulator for an existing project dir + mcp-android Enable Android MCP (android-connection) + nested Android emulator for an existing project dir attach, tmux Attach to an existing docker-git project workspace with tmux panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) @@ -80,7 +80,7 @@ Options: --up | --no-up Run docker compose up after init (default: --up) --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright) - --mcp-android | --no-mcp-android Enable Android MCP (mobile-mcp) + nested Android emulator sidecar (default: --no-mcp-android) + --mcp-android | --no-mcp-android Enable Android MCP (android-connection) + nested Android emulator sidecar (default: --no-mcp-android) --auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available -d, --daemon browser: run the browser frontend server in the background after build --active apply-all: apply only to currently running containers (skip stopped ones) diff --git a/packages/app/src/docker-git/menu-create-labels.ts b/packages/app/src/docker-git/menu-create-labels.ts index 1cbaa235..27a62399 100644 --- a/packages/app/src/docker-git/menu-create-labels.ts +++ b/packages/app/src/docker-git/menu-create-labels.ts @@ -25,10 +25,7 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): ), Match.when( "mcpAndroid", - () => - `Enable Android MCP (nested Android emulator)? [${ - renderExplicitBooleanChoice(defaults.enableMcpAndroid) - }]` + () => `Enable Android MCP (nested Android emulator)? [${renderExplicitBooleanChoice(defaults.enableMcpAndroid)}]` ), Match.when( "force", diff --git a/packages/container/src/core/templates-entrypoint/claude.ts b/packages/container/src/core/templates-entrypoint/claude.ts index 8571e802..599fd24a 100644 --- a/packages/container/src/core/templates-entrypoint/claude.ts +++ b/packages/container/src/core/templates-entrypoint/claude.ts @@ -255,8 +255,12 @@ const renderClaudeMcpAndroidConfig = (): string => String.raw`# Claude Code: keep Android MCP config in sync with container settings CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" docker_git_sync_claude_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + [[ -n "$android_project" ]] || android_project="$(hostname)" + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" - CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' + [[ -n "$adb_endpoint" ]] || adb_endpoint="$android_project-android:5555" + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") @@ -264,8 +268,12 @@ const settingsPath = process.env.CLAUDE_SETTINGS_FILE if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) const enableAndroid = process.env.MCP_ANDROID_ENABLE === "1" +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" -const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) let settings = {} @@ -278,12 +286,7 @@ try { const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} const nextServers = { ...currentServers } if (enableAndroid) { - nextServers.android = { - type: "stdio", - command: "bash", - args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], - env: {} - } + nextServers.android = { type: "stdio", command: "android-connection", args: androidArgs, env: {} } } else { delete nextServers.android } diff --git a/packages/container/src/core/templates-entrypoint/codex.ts b/packages/container/src/core/templates-entrypoint/codex.ts index 7b3c7546..564ade11 100644 --- a/packages/container/src/core/templates-entrypoint/codex.ts +++ b/packages/container/src/core/templates-entrypoint/codex.ts @@ -132,19 +132,27 @@ export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => .replaceAll("__CODEX_HOME__", () => config.codexHome) .replaceAll("__SERVICE_NAME__", () => config.serviceName) -// CHANGE: configure the Android MCP server (mobile-mcp) for Codex, mirroring the Playwright block +// CHANGE: configure the first-party Android MCP server for Codex, mirroring the Playwright block // WHY: issue-436 asks to wire mcp-android "the same way" Playwright MCP works; Codex reads its // MCP servers from config.toml, so we add/remove an [mcp_servers.android] entry to match the build // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 -// SOURCE: https://github.com/mobile-next/mobile-mcp -const entrypointMcpAndroidTemplate = String.raw`# Optional: configure Android MCP for Codex (mobile-mcp over ADB) +// SOURCE: n/a +const entrypointMcpAndroidTemplate = String.raw`# Optional: configure Android MCP for Codex (Rust android-connection) CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" +DOCKER_GIT_ANDROID_PROJECT="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" +if [[ -z "$DOCKER_GIT_ANDROID_PROJECT" ]]; then + DOCKER_GIT_ANDROID_PROJECT="$(hostname)" +fi +DOCKER_GIT_ANDROID_NETWORK="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$DOCKER_GIT_ANDROID_PROJECT}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" +if [[ -z "$DOCKER_GIT_ANDROID_ADB_ENDPOINT" ]]; then + DOCKER_GIT_ANDROID_ADB_ENDPOINT="$DOCKER_GIT_ANDROID_PROJECT-android:5555" +fi # Keep config.toml consistent with the container build. # If Android MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn the mobile-mcp server. +# doesn't try (and fail) to spawn android-connection. if [[ "$MCP_ANDROID_ENABLE" != "1" ]]; then if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' @@ -200,10 +208,10 @@ EOF cat <> "$CODEX_CONFIG_FILE" -# docker-git: Android MCP (mobile-mcp over ADB) +# docker-git: Android MCP (rust android-connection) [mcp_servers.android] -command = "bash" -args = ["-lc", "adb connect $DOCKER_GIT_ANDROID_ADB_ENDPOINT >/dev/null 2>&1 || true; exec npx -y @mobilenext/mobile-mcp@latest"] +command = "android-connection" +args = ["--project", "$DOCKER_GIT_ANDROID_PROJECT", "--network", "$DOCKER_GIT_ANDROID_NETWORK", "--endpoint", "$DOCKER_GIT_ANDROID_ADB_ENDPOINT", "--workspace", "$TARGET_DIR"] EOF fi` diff --git a/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts index 180f55a0..f1a92e2c 100644 --- a/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts +++ b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts @@ -3,12 +3,20 @@ // render helper in a dedicated file keeps gemini.ts under the max-lines lint budget // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 -// SOURCE: https://github.com/mobile-next/mobile-mcp +// SOURCE: n/a export const renderGeminiMcpAndroidConfig = (): string => String.raw`# Gemini CLI: keep Android MCP config in sync with container settings docker_git_sync_gemini_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + if [[ -z "$android_project" ]]; then + android_project="$(hostname)" + fi + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" - GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' + if [[ -z "$adb_endpoint" ]]; then + adb_endpoint="$android_project-android:5555" + fi + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE @@ -21,11 +29,17 @@ try { if (isRecord(parsed)) settings = parsed } catch {} +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" -const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 + ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] + : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } if (process.env.MCP_ANDROID_ENABLE === "1") { - nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } + nextServers.android = { command: "android-connection", args: androidArgs, trust: true } } else { delete nextServers.android } diff --git a/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts index 0dc9faac..84c529a5 100644 --- a/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts +++ b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts @@ -3,12 +3,20 @@ // render helper in a dedicated file keeps grok.ts under the max-lines lint budget // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 -// SOURCE: https://github.com/mobile-next/mobile-mcp +// SOURCE: n/a export const renderGrokMcpAndroidConfig = (): string => String.raw`# Grok CLI: keep Android MCP config in sync with container settings docker_git_sync_grok_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + if [[ -z "$android_project" ]]; then + android_project="$(hostname)" + fi + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" - GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" node - <<'NODE' + if [[ -z "$adb_endpoint" ]]; then + adb_endpoint="$android_project-android:5555" + fi + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' const fs = require("node:fs") const path = require("node:path") const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE @@ -21,11 +29,17 @@ try { if (isRecord(parsed)) settings = parsed } catch {} +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" -const connectPrefix = adbEndpoint.length > 0 ? "adb connect " + adbEndpoint + " >/dev/null 2>&1 || true; " : "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 + ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] + : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } if (process.env.MCP_ANDROID_ENABLE === "1") { - nextServers.android = { command: "bash", args: ["-lc", connectPrefix + "exec npx -y @mobilenext/mobile-mcp@latest"], trust: true } + nextServers.android = { command: "android-connection", args: androidArgs, trust: true } } else { delete nextServers.android } diff --git a/packages/container/src/core/templates/docker-compose-android.ts b/packages/container/src/core/templates/docker-compose-android.ts index 4269a244..f0e03d56 100644 --- a/packages/container/src/core/templates/docker-compose-android.ts +++ b/packages/container/src/core/templates/docker-compose-android.ts @@ -1,8 +1,8 @@ import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" -// CHANGE: render an Android emulator sidecar service mirroring the Playwright MCP wiring +// CHANGE: render an Android emulator sidecar service for the first-party Android MCP wiring // WHY: issue-436 asks to connect mcp-android "the same way" Playwright MCP works, exposing -// a docker-android emulator as a service reachable over ADB for the mobile-mcp server. +// a docker-android emulator as a service reachable over ADB for android-connection. // Extracted into its own module so docker-compose.ts stays under the max-lines budget. // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 @@ -35,7 +35,7 @@ export const buildAndroidFragments = ( const networkName = resolveComposeNetworkName(config) const maybeAndroidEnv = - ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` + ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_PROJECT: "${config.containerName}"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` const maybeAndroidService = ` ${config.serviceName}-android: diff --git a/packages/container/src/core/templates/dockerfile.ts b/packages/container/src/core/templates/dockerfile.ts index e9dd548a..03f18da9 100644 --- a/packages/container/src/core/templates/dockerfile.ts +++ b/packages/container/src/core/templates/dockerfile.ts @@ -91,21 +91,23 @@ const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => # Old browser-vnc + cdp-guard duplication removed per #347` : "" -// CHANGE: install the ADB client when Android MCP is enabled -// WHY: the mobile-mcp server (launched via npx at runtime) drives the docker-android -// emulator sidecar over ADB, so the dev container needs an `adb` binary to connect +// CHANGE: install the first-party Android MCP module when Android MCP is enabled +// WHY: issue-436 requires a separately proven module instead of an unpinned runtime npx server // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 -// SOURCE: https://github.com/budtmo/docker-android (ADB on 5555) +// SOURCE: n/a // PURITY: CORE (pure template renderer) const renderDockerfileAndroidRuntime = (config: TemplateConfig): string => config.enableMcpAndroid === true - ? `# Android MCP runtime: ADB client to reach the dg-*-android emulator sidecar. -# The MCP server itself (@mobilenext/mobile-mcp) is launched on demand via npx (Node 24 above). + ? `# Android MCP runtime: ADB client + first-party Rust android-connection module. +COPY .docker-git-tools/android-connection /opt/docker-git/tools/android-connection RUN apt-get update \ && apt-get install -y --no-install-recommends android-tools-adb \ && rm -rf /var/lib/apt/lists/* \ - && adb --version` + && adb --version \ + && cargo install --path /opt/docker-git/tools/android-connection --locked --bins --root /usr/local \ + && /usr/local/bin/docker-git-android-connection --version \ + && /usr/local/bin/android-connection --version` : "" /** diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index 40ef7300..dbd419a3 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -1144,6 +1144,7 @@ describe("renderDockerCompose", () => { ) expect(compose).toContain('MCP_ANDROID_ENABLE: "1"') + expect(compose).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') expect(compose).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') expect(compose).toContain( 'DOCKER_GIT_ANDROID_ADB_ENDPOINT: "${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-dg-test-android:5555}"' @@ -1171,12 +1172,16 @@ describe("renderDockerCompose", () => { expect(compose).not.toContain("/dev/kvm") }) - it("installs the ADB client in the Dockerfile only when Android MCP is enabled", () => { + it("installs the first-party Android connection module only when Android MCP is enabled", () => { const enabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: true })) const disabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: false })) + expect(enabled).toContain("COPY .docker-git-tools/android-connection") expect(enabled).toContain("android-tools-adb") - expect(enabled).toContain("adb --version") + expect(enabled).toContain("cargo install --path /opt/docker-git/tools/android-connection") + expect(enabled).toContain("/usr/local/bin/docker-git-android-connection --version") + expect(enabled).toContain("/usr/local/bin/android-connection --version") + expect(disabled).not.toContain(".docker-git-tools/android-connection") expect(disabled).not.toContain("android-tools-adb") }) @@ -1186,11 +1191,14 @@ describe("renderDockerCompose", () => { expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-1}"') // Codex (TOML) expect(entrypoint).toContain("[mcp_servers.android]") - expect(entrypoint).toContain("@mobilenext/mobile-mcp@latest") + expect(entrypoint).toContain('command = "android-connection"') + expect(entrypoint).toContain('"--endpoint", "$DOCKER_GIT_ANDROID_ADB_ENDPOINT"') + expect(entrypoint).not.toContain("@mobilenext/mobile-mcp") // Claude / Gemini / Grok (JSON sync helpers) expect(entrypoint).toContain("docker_git_sync_claude_android_mcp") expect(entrypoint).toContain("docker_git_sync_gemini_android_mcp") expect(entrypoint).toContain("docker_git_sync_grok_android_mcp") + expect(entrypoint).toContain('command: "android-connection"') }) it("defaults MCP_ANDROID_ENABLE to 0 when Android MCP is disabled", () => { diff --git a/packages/lib/src/shell/android-connection-source.ts b/packages/lib/src/shell/android-connection-source.ts new file mode 100644 index 00000000..063f5fca --- /dev/null +++ b/packages/lib/src/shell/android-connection-source.ts @@ -0,0 +1,149 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { resolveWorkspaceRoot } from "./workspace-root.js" + +const androidConnectionToolRelativePath = ".docker-git-tools/android-connection" + +const ensureParentDir = ( + path: Path.Path, + fs: FileSystem.FileSystem, + filePath: string +) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) + +const resolveFileUrlPath = (fileUrl: string): string => { + const url = new URL(fileUrl) + return url.protocol === "file:" ? decodeURIComponent(url.pathname) : fileUrl +} + +const shouldSkipAndroidConnectionEntry = (entry: string): boolean => entry === "target" || entry === ".git" + +const copyTextFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const contents = yield* _(fs.readFileString(sourcePath)) + yield* _(ensureParentDir(path, fs, targetPath)) + yield* _(fs.writeFileString(targetPath, contents)) + }) + +const copyTextDirectoryEntry = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const info = yield* _(fs.stat(sourcePath)) + if (info.type === "Directory") { + yield* _(copyTextDirectory(fs, path, sourcePath, targetPath)) + return + } + if (info.type === "File") { + yield* _(copyTextFile(fs, path, sourcePath, targetPath)) + } + }) + +const copyTextDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(fs.makeDirectory(targetPath, { recursive: true })) + const entries = yield* _(fs.readDirectory(sourcePath)) + for (const entry of entries) { + if (shouldSkipAndroidConnectionEntry(entry)) { + continue + } + yield* _( + copyTextDirectoryEntry( + fs, + path, + path.join(sourcePath, entry), + path.join(targetPath, entry) + ) + ) + } + }) + +const androidConnectionSourceCandidates = ( + path: Path.Path, + workspaceRoot: string +): ReadonlyArray => [ + path.join(workspaceRoot, "crates", "android-connection"), + path.join( + path.dirname(resolveFileUrlPath(import.meta.url)), + "..", + "..", + "..", + "..", + "crates", + "android-connection" + ) +] + +const firstExistingDirectory = ( + fs: FileSystem.FileSystem, + candidates: ReadonlyArray +): Effect.Effect => + Effect.gen(function*(_) { + for (const candidate of candidates) { + const isExists = yield* _(fs.exists(candidate)) + if (!isExists) { + continue + } + const info = yield* _(fs.stat(candidate)) + if (info.type === "Directory") { + return candidate + } + } + return null + }) + +// CHANGE: provision the first-party Android MCP Rust source into the Docker build context +// WHY: the generated Dockerfile installs android-connection with cargo install --path --locked +// QUOTE(ТЗ): "Сперва нужно отдельно реализовать сам модуль и доказать что он работает" +// REF: issue-436 +// SOURCE: n/a +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: enabled Android MCP builds from the same audited crate source as local tests +// COMPLEXITY: O(n) where n = |android_connection_source_files| +export const provisionDockerGitAndroidConnectionSource = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) + const sourcePath = yield* _( + firstExistingDirectory( + fs, + androidConnectionSourceCandidates(path, workspaceRoot) + ) + ) + if (sourcePath === null) { + yield* _( + Effect.dieMessage( + "android-connection source not found; expected crates/android-connection in the docker-git workspace" + ) + ) + return + } + + yield* _( + copyTextDirectory( + fs, + path, + sourcePath, + path.join(baseDir, androidConnectionToolRelativePath) + ) + ) + }) diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 8036ef28..98c475fd 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -11,6 +11,7 @@ import { withDefaultResourceLimitIntent } from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" +import { provisionDockerGitAndroidConnectionSource } from "./android-connection-source.js" import { resolveDockerEnvValue } from "./docker-auth.js" import { FileExistsError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -214,6 +215,20 @@ const provisionDockerGitSessionSyncTool = ( ) }) +const provisionDockerGitBuildContext = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string, + config: TemplateConfig +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(provisionDockerGitScripts(fs, path, baseDir)) + yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) + if (config.enableMcpAndroid === true) { + yield* _(provisionDockerGitAndroidConnectionSource(fs, path, baseDir)) + } + }) + // CHANGE: write generated docker-git files to disk // WHY: isolate all filesystem effects in a thin shell // QUOTE(ТЗ): "создавать докер образы" @@ -267,11 +282,7 @@ export const writeProjectFiles = ( } } - // CHANGE: provision docker-git scripts into project build context - // WHY: Dockerfile COPY scripts/ requires scripts to be in the build context - // REF: issue-176 - yield* _(provisionDockerGitScripts(fs, path, baseDir)) - yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) + yield* _(provisionDockerGitBuildContext(fs, path, baseDir, normalizedConfig)) return created }) diff --git a/packages/lib/src/usecases/mcp-android.ts b/packages/lib/src/usecases/mcp-android.ts index d6c26a39..96b106de 100644 --- a/packages/lib/src/usecases/mcp-android.ts +++ b/packages/lib/src/usecases/mcp-android.ts @@ -28,7 +28,7 @@ const enableInTemplate = (template: TemplateConfig): TemplateConfig => ({ }) // CHANGE: enable Android MCP in an existing docker-git project directory (files only) -// WHY: allow adding the nested Android emulator sidecar + mobile-mcp server config without wiping env or volumes +// WHY: allow adding the Android emulator sidecar + android-connection MCP config without wiping env or volumes // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 // SOURCE: n/a @@ -85,6 +85,6 @@ export const mcpAndroidUp = ( return updated } - yield* _(ensureDockerDaemonAccess(process.cwd())) + yield* _(ensureDockerDaemonAccess(command.projectDir)) return yield* _(runDockerComposeUpWithPortCheck(command.projectDir)) }) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index 3c39f51a..11cdf177 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -227,6 +227,7 @@ describe("applyProjectFiles", () => { cpuLimit: "2", ramLimit: "4g", enableMcpPlaywright: true, + enableMcpAndroid: true, gpu: "none", }) ) @@ -236,6 +237,7 @@ describe("applyProjectFiles", () => { expect(appliedTemplate.cpuLimit).toBe("2") expect(appliedTemplate.ramLimit).toBe("4g") expect(appliedTemplate.enableMcpPlaywright).toBe(true) + expect(appliedTemplate.enableMcpAndroid).toBe(true) const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(composeAfter).toContain('GITHUB_AUTH_LABEL: "AGIEN_MAIN"') @@ -247,10 +249,17 @@ describe("applyProjectFiles", () => { expect(composeAfter).toContain('memswap_limit: "8192m"') expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"') expect(composeAfter).toContain("dg-test-browser") + expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(composeAfter).toContain("dg-test-android") + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain("cargo install --path /opt/docker-git/tools/android-connection") const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) expect(configAfter).toContain('"cpuLimit": "2"') expect(configAfter).toContain('"ramLimit": "4g"') + expect(configAfter).toContain('"enableMcpAndroid": true') }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/usecases/mcp-android.test.ts b/packages/lib/tests/usecases/mcp-android.test.ts index eed53471..b40fd6a3 100644 --- a/packages/lib/tests/usecases/mcp-android.test.ts +++ b/packages/lib/tests/usecases/mcp-android.test.ts @@ -121,11 +121,22 @@ describe("enableMcpAndroidProjectFiles", () => { const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(composeAfter).toContain("dg-test-android") expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') expect(composeAfter).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') expect(composeAfter).toContain("/dev/kvm") const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) expect(dockerfileAfter).toContain("android-tools-adb") + expect(dockerfileAfter).toContain("cargo install --path /opt/docker-git/tools/android-connection") + expect(dockerfileAfter).toContain("/usr/local/bin/android-connection --version") + + const androidConnectionCargoToml = path.join( + outDir, + ".docker-git-tools", + "android-connection", + "Cargo.toml" + ) + expect(yield* _(fs.exists(androidConnectionCargoToml))).toBe(true) const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText))) From 000bb520f07988f40f1889222797e2a72d67ebd1 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:48:33 +0000 Subject: [PATCH 11/11] refactor(container): install android connection from rust repo --- .github/workflows/check.yml | 20 - crates/android-connection/.gitignore | 1 - crates/android-connection/Cargo.lock | 249 ------- crates/android-connection/Cargo.toml | 24 - crates/android-connection/README.md | 10 - .../src/bin/android-connection.rs | 54 -- crates/android-connection/src/lib.rs | 253 ------- crates/android-connection/src/main.rs | 104 --- crates/android-connection/src/mcp.rs | 635 ------------------ .../src/core/templates/dockerfile.ts | 8 +- .../container/tests/core/templates.test.ts | 6 +- .../src/shell/android-connection-source.ts | 149 ---- packages/lib/src/shell/files.ts | 9 +- packages/lib/tests/usecases/apply.test.ts | 4 +- .../lib/tests/usecases/mcp-android.test.ts | 13 +- 15 files changed, 17 insertions(+), 1522 deletions(-) delete mode 100644 crates/android-connection/.gitignore delete mode 100644 crates/android-connection/Cargo.lock delete mode 100644 crates/android-connection/Cargo.toml delete mode 100644 crates/android-connection/README.md delete mode 100644 crates/android-connection/src/bin/android-connection.rs delete mode 100644 crates/android-connection/src/lib.rs delete mode 100644 crates/android-connection/src/main.rs delete mode 100644 crates/android-connection/src/mcp.rs delete mode 100644 packages/lib/src/shell/android-connection-source.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0312df2a..35247746 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,26 +39,6 @@ jobs: - name: Build (api) run: bun run --cwd packages/api build - rust-android-connection: - name: Rust Android connection - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - name: Rust version - run: | - rustc --version - cargo --version - - name: Format - working-directory: crates/android-connection - run: cargo fmt --check - - name: Test - working-directory: crates/android-connection - run: cargo test --locked - - name: Clippy - working-directory: crates/android-connection - run: cargo clippy --locked --all-targets -- -D warnings - dist-deps-prune: name: Dist deps prune runs-on: ubuntu-latest diff --git a/crates/android-connection/.gitignore b/crates/android-connection/.gitignore deleted file mode 100644 index 2f7896d1..00000000 --- a/crates/android-connection/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/crates/android-connection/Cargo.lock b/crates/android-connection/Cargo.lock deleted file mode 100644 index 038d1da3..00000000 --- a/crates/android-connection/Cargo.lock +++ /dev/null @@ -1,249 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "docker-git-android-connection" -version = "0.1.0" -dependencies = [ - "clap", - "serde", - "serde_json", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/android-connection/Cargo.toml b/crates/android-connection/Cargo.toml deleted file mode 100644 index 87c88919..00000000 --- a/crates/android-connection/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "docker-git-android-connection" -version = "0.1.0" -edition = "2021" -description = "First-party Android MCP and lifecycle module for docker-git" -license = "MIT" -repository = "https://github.com/ProverCoderAI/docker-git" - -[lib] -name = "docker_git_android_connection" -path = "src/lib.rs" - -[[bin]] -name = "docker-git-android-connection" -path = "src/main.rs" - -[[bin]] -name = "android-connection" -path = "src/bin/android-connection.rs" - -[dependencies] -clap = { version = "4.5.53", features = ["derive"] } -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" diff --git a/crates/android-connection/README.md b/crates/android-connection/README.md deleted file mode 100644 index 17b14d76..00000000 --- a/crates/android-connection/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# docker-git Android connection - -First-party Android MCP module for docker-git. - -The crate provides two binaries: - -- `android-connection`: MCP stdio server used by Codex, Claude, Gemini, and Grok. -- `docker-git-android-connection`: lifecycle CLI for deterministic Android runtime naming and Docker command construction. - -The core module keeps deterministic naming, endpoint validation, and tool specifications pure. Shell effects are isolated in the binaries and MCP tool handlers. diff --git a/crates/android-connection/src/bin/android-connection.rs b/crates/android-connection/src/bin/android-connection.rs deleted file mode 100644 index 8366df39..00000000 --- a/crates/android-connection/src/bin/android-connection.rs +++ /dev/null @@ -1,54 +0,0 @@ -use clap::Parser; -use docker_git_android_connection::mcp::{run_stdio, McpState}; -use docker_git_android_connection::{ - android_spec, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, -}; -use std::io::{self, BufReader}; -use std::path::PathBuf; -use std::process::ExitCode; - -#[derive(Parser, Debug)] -#[command(version, about = "Android MCP stdio server for docker-git")] -struct Cli { - #[arg(long, default_value = DEFAULT_PROJECT_ID)] - project: String, - #[arg(long, default_value = "docker-git-shared")] - network: String, - #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] - endpoint: String, - #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] - image: String, - #[arg(long, default_value = ".")] - workspace: PathBuf, - #[arg(long)] - allow_install: bool, - #[arg(long)] - no_adb_probe: bool, -} - -fn main() -> ExitCode { - match run() { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - ExitCode::from(1) - } - } -} - -fn run() -> Result<(), Box> { - let cli = Cli::parse(); - let spec = android_spec(&cli.project, &cli.network, &cli.endpoint, &cli.image)?; - let state = McpState { - spec, - workspace: cli.workspace, - adb_probe: !cli.no_adb_probe, - allow_install: cli.allow_install, - }; - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut reader = BufReader::new(stdin.lock()); - let mut writer = stdout.lock(); - run_stdio(&mut reader, &mut writer, state)?; - Ok(()) -} diff --git a/crates/android-connection/src/lib.rs b/crates/android-connection/src/lib.rs deleted file mode 100644 index 54c82f6b..00000000 --- a/crates/android-connection/src/lib.rs +++ /dev/null @@ -1,253 +0,0 @@ -pub mod mcp; - -use serde::Serialize; - -pub const SERVER_NAME: &str = "android-connection"; -pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; -pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; -pub const DEFAULT_PROJECT_ID: &str = "docker-git"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct EndpointError { - pub value: String, -} - -impl std::fmt::Display for EndpointError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - "invalid ADB endpoint {:?}; allowed characters are ASCII letters, digits, '.', '-', '_' and ':'", - self.value - ) - } -} - -impl std::error::Error for EndpointError {} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub struct AndroidSpec { - pub project_id: String, - pub project_container_name: String, - pub android_container_name: String, - pub android_volume_name: String, - pub docker_network: String, - pub adb_endpoint: String, - pub image: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub struct McpToolSpec { - pub name: &'static str, - pub description: &'static str, -} - -// CHANGE: normalize externally supplied project ids into Docker-safe names -// WHY: Android sidecar names are pure functions of the project id, so MCP clients and lifecycle CLI agree -// QUOTE(TZ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" -// REF: issue-436 -// SOURCE: n/a -// FORMAT THEOREM: forall s: normalize(s) in [a-z0-9-]+ and normalize(s) != "" -// PURITY: CORE -// INVARIANT: output is non-empty, lowercase, and contains only Docker-name-safe characters -// COMPLEXITY: O(n)/O(n) -pub fn normalize_project_id(raw: &str) -> String { - let mut normalized = String::new(); - let mut previous_dash = false; - - for byte in raw.bytes() { - let next = match byte { - b'a'..=b'z' | b'0'..=b'9' => Some(byte as char), - b'A'..=b'Z' => Some(byte.to_ascii_lowercase() as char), - _ => { - if normalized.is_empty() || previous_dash { - None - } else { - Some('-') - } - } - }; - - if let Some(character) = next { - previous_dash = character == '-'; - normalized.push(character); - } - } - - while normalized.ends_with('-') { - normalized.pop(); - } - - if normalized.is_empty() { - DEFAULT_PROJECT_ID.to_string() - } else { - normalized - } -} - -pub fn android_container_name(project_id: &str) -> String { - format!("{}-android", normalize_project_id(project_id)) -} - -pub fn android_volume_name(project_id: &str) -> String { - format!("{}-home-android", normalize_project_id(project_id)) -} - -pub fn is_safe_adb_endpoint(value: &str) -> bool { - !value.is_empty() - && value.len() <= 255 - && value.contains(':') - && value.bytes().all(|byte| { - matches!( - byte, - b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b':' - ) - }) -} - -pub fn validate_adb_endpoint(value: &str) -> Result { - if is_safe_adb_endpoint(value) { - Ok(value.to_string()) - } else { - Err(EndpointError { - value: value.to_string(), - }) - } -} - -pub fn android_spec( - project_id: &str, - docker_network: &str, - adb_endpoint: &str, - image: &str, -) -> Result { - let normalized = normalize_project_id(project_id); - Ok(AndroidSpec { - project_id: normalized.clone(), - project_container_name: normalized.clone(), - android_container_name: android_container_name(&normalized), - android_volume_name: android_volume_name(&normalized), - docker_network: docker_network.to_string(), - adb_endpoint: validate_adb_endpoint(adb_endpoint)?, - image: image.to_string(), - }) -} - -pub fn android_tools() -> Vec { - vec![ - McpToolSpec { - name: "android_status", - description: "Return the configured Android runtime and optional ADB status.", - }, - McpToolSpec { - name: "android_devices", - description: "List Android devices visible to adb.", - }, - McpToolSpec { - name: "android_screenshot", - description: "Capture a PNG screenshot into the workspace.", - }, - McpToolSpec { - name: "android_tap", - description: "Tap screen coordinates.", - }, - McpToolSpec { - name: "android_swipe", - description: "Swipe between screen coordinates.", - }, - McpToolSpec { - name: "android_type_text", - description: "Type text into the active Android input field.", - }, - McpToolSpec { - name: "android_press_key", - description: "Send an Android keycode.", - }, - McpToolSpec { - name: "android_launch_app", - description: "Launch an installed Android package.", - }, - McpToolSpec { - name: "android_open_url", - description: "Open a URL through Android intent handling.", - }, - McpToolSpec { - name: "android_logcat", - description: "Read recent logcat output.", - }, - McpToolSpec { - name: "android_install_apk", - description: "Install an APK from the workspace when explicitly enabled.", - }, - ] -} - -pub fn docker_run_args(spec: &AndroidSpec) -> Vec { - vec![ - "run".to_string(), - "--detach".to_string(), - "--name".to_string(), - spec.android_container_name.clone(), - "--privileged".to_string(), - "--network".to_string(), - spec.docker_network.clone(), - "--env".to_string(), - "EMULATOR_HEADLESS=true".to_string(), - "--env".to_string(), - "WEB_VNC=true".to_string(), - "--volume".to_string(), - format!("{}:/root/.android", spec.android_volume_name), - spec.image.clone(), - ] -} - -pub fn docker_stop_args(spec: &AndroidSpec) -> Vec { - vec![ - "rm".to_string(), - "--force".to_string(), - spec.android_container_name.clone(), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalizes_project_id_to_docker_safe_name() { - assert_eq!( - normalize_project_id("Org/Repo:Feature_X"), - "org-repo-feature-x" - ); - assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); - } - - #[test] - fn rejects_shell_fragments_in_adb_endpoint() { - assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); - assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); - assert!(validate_adb_endpoint("$(whoami):5555").is_err()); - } - - #[test] - fn builds_deterministic_android_spec() { - let spec = android_spec( - "dg-test", - "docker-git-shared", - "dg-test-android:5555", - DEFAULT_ANDROID_IMAGE, - ) - .expect("valid spec"); - - assert_eq!(spec.project_container_name, "dg-test"); - assert_eq!(spec.android_container_name, "dg-test-android"); - assert_eq!(spec.android_volume_name, "dg-test-home-android"); - } - - #[test] - fn advertises_android_mcp_tools() { - let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); - assert!(names.contains(&"android_status")); - assert!(names.contains(&"android_tap")); - assert!(names.contains(&"android_install_apk")); - } -} diff --git a/crates/android-connection/src/main.rs b/crates/android-connection/src/main.rs deleted file mode 100644 index 2dc0fa43..00000000 --- a/crates/android-connection/src/main.rs +++ /dev/null @@ -1,104 +0,0 @@ -use clap::{Args, Parser, Subcommand}; -use docker_git_android_connection::{ - android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, - DEFAULT_PROJECT_ID, -}; -use serde_json::json; -use std::process::{Command, ExitCode}; - -#[derive(Parser, Debug)] -#[command(version, about = "docker-git Android runtime lifecycle CLI")] -struct Cli { - #[command(subcommand)] - command: LifecycleCommand, -} - -#[derive(Subcommand, Debug)] -enum LifecycleCommand { - Start(LifecycleArgs), - Status(LifecycleArgs), - Stop(LifecycleArgs), -} - -#[derive(Args, Clone, Debug)] -struct LifecycleArgs { - #[arg(long, default_value = DEFAULT_PROJECT_ID)] - project: String, - #[arg(long, default_value = "docker-git-shared")] - network: String, - #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] - endpoint: String, - #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] - image: String, - #[arg(long)] - dry_run: bool, -} - -fn main() -> ExitCode { - match run() { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - ExitCode::from(1) - } - } -} - -fn run() -> Result<(), Box> { - let cli = Cli::parse(); - match cli.command { - LifecycleCommand::Start(args) => start(args), - LifecycleCommand::Status(args) => status(args), - LifecycleCommand::Stop(args) => stop(args), - } -} - -fn start(args: LifecycleArgs) -> Result<(), Box> { - let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - let docker_args = docker_run_args(&spec); - if args.dry_run { - println!( - "{}", - serde_json::to_string_pretty(&json!({ "docker": docker_args }))? - ); - return Ok(()); - } - - run_docker(&docker_args) -} - -fn status(args: LifecycleArgs) -> Result<(), Box> { - let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - println!("{}", serde_json::to_string_pretty(&spec)?); - Ok(()) -} - -fn stop(args: LifecycleArgs) -> Result<(), Box> { - let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - let docker_args = docker_stop_args(&spec); - if args.dry_run { - println!( - "{}", - serde_json::to_string_pretty(&json!({ "docker": docker_args }))? - ); - return Ok(()); - } - - run_docker(&docker_args) -} - -fn run_docker(args: &[String]) -> Result<(), Box> { - let output = Command::new("docker").args(args).output()?; - if output.status.success() { - print!("{}", String::from_utf8_lossy(&output.stdout)); - return Ok(()); - } - - Err(format!( - "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", - output.status.code(), - String::from_utf8_lossy(&output.stdout).trim(), - String::from_utf8_lossy(&output.stderr).trim() - ) - .into()) -} diff --git a/crates/android-connection/src/mcp.rs b/crates/android-connection/src/mcp.rs deleted file mode 100644 index d4a6dec3..00000000 --- a/crates/android-connection/src/mcp.rs +++ /dev/null @@ -1,635 +0,0 @@ -use crate::{android_tools, AndroidSpec, SERVER_NAME}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::fs; -use std::io::{self, BufRead, Write}; -use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Output}; - -#[derive(Clone, Debug)] -pub struct McpState { - pub spec: AndroidSpec, - pub workspace: PathBuf, - pub adb_probe: bool, - pub allow_install: bool, -} - -#[derive(Debug)] -enum McpToolError { - MissingArgument(&'static str), - InvalidArgument(String), - AdbProbeDisabled, - CommandFailed(String), - Io(String), -} - -impl std::fmt::Display for McpToolError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::MissingArgument(name) => write!(formatter, "missing required argument: {name}"), - Self::InvalidArgument(message) => write!(formatter, "invalid argument: {message}"), - Self::AdbProbeDisabled => write!(formatter, "ADB probing is disabled for this server"), - Self::CommandFailed(message) => write!(formatter, "{message}"), - Self::Io(message) => write!(formatter, "{message}"), - } - } -} - -#[derive(Debug, Deserialize)] -struct JsonRpcRequest { - id: Option, - method: String, - params: Option, -} - -pub fn run_stdio(reader: &mut R, writer: &mut W, state: McpState) -> io::Result<()> -where - R: BufRead, - W: Write, -{ - while let Some(raw) = read_next_message(reader)? { - let response = match serde_json::from_str::(&raw) { - Ok(request) => handle_request(&request, &state), - Err(error) => Some(json_rpc_error( - Value::Null, - -32700, - &format!("invalid JSON-RPC request: {error}"), - )), - }; - - if let Some(value) = response { - write_json_message(writer, &value)?; - } - } - - Ok(()) -} - -fn read_next_message(reader: &mut R) -> io::Result> { - loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line)?; - if bytes_read == 0 { - return Ok(None); - } - - let first_line = line.trim_end_matches(['\r', '\n']); - if first_line.is_empty() { - continue; - } - - if let Some(length) = parse_content_length(first_line)? { - read_headers(reader)?; - let mut payload = vec![0_u8; length]; - reader.read_exact(&mut payload)?; - return String::from_utf8(payload) - .map(Some) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); - } - - return Ok(Some(first_line.to_string())); - } -} - -fn parse_content_length(header: &str) -> io::Result> { - let lowercase = header.to_ascii_lowercase(); - if !lowercase.starts_with("content-length:") { - return Ok(None); - } - - let raw_length = header - .split_once(':') - .map(|(_, value)| value.trim()) - .unwrap_or_default(); - raw_length - .parse::() - .map(Some) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) -} - -fn read_headers(reader: &mut R) -> io::Result<()> { - loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line)?; - if bytes_read == 0 { - return Ok(()); - } - if line.trim().is_empty() { - return Ok(()); - } - } -} - -fn write_json_message(writer: &mut W, value: &Value) -> io::Result<()> { - let body = serde_json::to_string(value) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?; - writer.flush() -} - -fn handle_request(request: &JsonRpcRequest, state: &McpState) -> Option { - if request.id.is_none() && request.method.starts_with("notifications/") { - return None; - } - - let id = request.id.clone().unwrap_or(Value::Null); - let response = match request.method.as_str() { - "initialize" => json!({ - "protocolVersion": "2024-11-05", - "capabilities": { "tools": {} }, - "serverInfo": { - "name": SERVER_NAME, - "version": env!("CARGO_PKG_VERSION") - } - }), - "tools/list" => json!({ "tools": render_tools() }), - "tools/call" => return Some(handle_tools_call(id, request.params.as_ref(), state)), - method => { - return Some(json_rpc_error( - id, - -32601, - &format!("method not found: {method}"), - )) - } - }; - - Some(json!({ - "jsonrpc": "2.0", - "id": id, - "result": response - })) -} - -fn json_rpc_error(id: Value, code: i64, message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": code, - "message": message - } - }) -} - -fn render_tools() -> Value { - Value::Array( - android_tools() - .into_iter() - .map(|tool| { - json!({ - "name": tool.name, - "description": tool.description, - "inputSchema": { - "type": "object", - "additionalProperties": true - } - }) - }) - .collect(), - ) -} - -fn handle_tools_call(id: Value, params: Option<&Value>, state: &McpState) -> Value { - let result = call_tool_from_params(params, state); - let (text, is_error) = match result { - Ok(text) => (text, false), - Err(error) => (error.to_string(), true), - }; - - json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "content": [ - { - "type": "text", - "text": text - } - ], - "isError": is_error - } - }) -} - -fn call_tool_from_params(params: Option<&Value>, state: &McpState) -> Result { - let params = - params.ok_or_else(|| McpToolError::InvalidArgument("missing params".to_string()))?; - let name = params - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| McpToolError::InvalidArgument("missing tool name".to_string()))?; - let arguments = params.get("arguments").unwrap_or(&Value::Null); - - match name { - "android_status" => android_status(state), - "android_devices" => android_devices(state), - "android_screenshot" => android_screenshot(state, arguments), - "android_tap" => android_tap(state, arguments), - "android_swipe" => android_swipe(state, arguments), - "android_type_text" => android_type_text(state, arguments), - "android_press_key" => android_press_key(state, arguments), - "android_launch_app" => android_launch_app(state, arguments), - "android_open_url" => android_open_url(state, arguments), - "android_logcat" => android_logcat(state, arguments), - "android_install_apk" => android_install_apk(state, arguments), - unknown => Err(McpToolError::InvalidArgument(format!( - "unknown Android MCP tool: {unknown}" - ))), - } -} - -fn android_status(state: &McpState) -> Result { - if !state.adb_probe { - return serde_json::to_string_pretty(&json!({ - "server": SERVER_NAME, - "adbProbe": false, - "spec": state.spec - })) - .map_err(|error| McpToolError::Io(error.to_string())); - } - - match run_adb(state, &["devices".to_string()]) { - Ok(output) => Ok(format!( - "Android runtime: {}\nADB endpoint: {}\n\n{}", - state.spec.android_container_name, state.spec.adb_endpoint, output - )), - Err(error) => Ok(format!( - "Android runtime: {}\nADB endpoint: {}\nADB status error: {}", - state.spec.android_container_name, state.spec.adb_endpoint, error - )), - } -} - -fn android_devices(state: &McpState) -> Result { - run_adb(state, &["devices".to_string()]) -} - -fn android_tap(state: &McpState, arguments: &Value) -> Result { - let x = integer_argument(arguments, "x")?; - let y = integer_argument(arguments, "y")?; - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "tap".to_string(), - x.to_string(), - y.to_string(), - ], - ) -} - -fn android_swipe(state: &McpState, arguments: &Value) -> Result { - let start_x = integer_argument(arguments, "startX")?; - let start_y = integer_argument(arguments, "startY")?; - let end_x = integer_argument(arguments, "endX")?; - let end_y = integer_argument(arguments, "endY")?; - let duration_ms = optional_integer_argument(arguments, "durationMs")?.unwrap_or(300); - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "swipe".to_string(), - start_x.to_string(), - start_y.to_string(), - end_x.to_string(), - end_y.to_string(), - duration_ms.to_string(), - ], - ) -} - -fn android_type_text(state: &McpState, arguments: &Value) -> Result { - let text = string_argument(arguments, "text")?.replace(' ', "%s"); - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "text".to_string(), - text, - ], - ) -} - -fn android_press_key(state: &McpState, arguments: &Value) -> Result { - let keycode = string_argument(arguments, "keycode")?; - if !keycode - .bytes() - .all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) - { - return Err(McpToolError::InvalidArgument( - "keycode may contain only ASCII letters, digits, and '_'".to_string(), - )); - } - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "keyevent".to_string(), - keycode, - ], - ) -} - -fn android_launch_app(state: &McpState, arguments: &Value) -> Result { - let package_name = string_argument(arguments, "package")?; - let activity = optional_string_argument(arguments, "activity")?; - match activity { - Some(activity) if !activity.is_empty() => run_adb( - state, - &[ - "shell".to_string(), - "am".to_string(), - "start".to_string(), - "-n".to_string(), - format!("{package_name}/{activity}"), - ], - ), - _ => run_adb( - state, - &[ - "shell".to_string(), - "monkey".to_string(), - "-p".to_string(), - package_name, - "-c".to_string(), - "android.intent.category.LAUNCHER".to_string(), - "1".to_string(), - ], - ), - } -} - -fn android_open_url(state: &McpState, arguments: &Value) -> Result { - let url = string_argument(arguments, "url")?; - run_adb( - state, - &[ - "shell".to_string(), - "am".to_string(), - "start".to_string(), - "-a".to_string(), - "android.intent.action.VIEW".to_string(), - "-d".to_string(), - url, - ], - ) -} - -fn android_logcat(state: &McpState, arguments: &Value) -> Result { - let lines = optional_integer_argument(arguments, "lines")? - .unwrap_or(200) - .clamp(1, 1000); - run_adb( - state, - &[ - "logcat".to_string(), - "-d".to_string(), - "-t".to_string(), - lines.to_string(), - ], - ) -} - -fn android_screenshot(state: &McpState, arguments: &Value) -> Result { - let output_path = optional_string_argument(arguments, "path")? - .unwrap_or_else(|| "android-screenshot.png".to_string()); - let target_path = workspace_path(&state.workspace, &output_path)?; - let output = run_adb_raw( - state, - &[ - "exec-out".to_string(), - "screencap".to_string(), - "-p".to_string(), - ], - )?; - if !output.status.success() { - return Err(McpToolError::CommandFailed(command_failure( - "adb screenshot", - output, - ))); - } - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).map_err(|error| McpToolError::Io(error.to_string()))?; - } - fs::write(&target_path, output.stdout).map_err(|error| McpToolError::Io(error.to_string()))?; - Ok(format!("screenshot written to {}", target_path.display())) -} - -fn android_install_apk(state: &McpState, arguments: &Value) -> Result { - if !state.allow_install { - return Err(McpToolError::InvalidArgument( - "APK installation requires --allow-install".to_string(), - )); - } - let apk_path = string_argument(arguments, "path")?; - let target_path = workspace_path(&state.workspace, &apk_path)?; - run_adb( - state, - &["install".to_string(), target_path.display().to_string()], - ) -} - -fn run_adb(state: &McpState, args: &[String]) -> Result { - let output = run_adb_raw(state, args)?; - output_to_text("adb", output) -} - -fn run_adb_raw(state: &McpState, args: &[String]) -> Result { - if !state.adb_probe { - return Err(McpToolError::AdbProbeDisabled); - } - - let connect_output = Command::new("adb") - .arg("connect") - .arg(&state.spec.adb_endpoint) - .output() - .map_err(|error| { - McpToolError::CommandFailed(format!("failed to execute adb connect: {error}")) - })?; - if !connect_output.status.success() { - return Err(McpToolError::CommandFailed(command_failure( - "adb connect", - connect_output, - ))); - } - - Command::new("adb") - .args(args) - .output() - .map_err(|error| McpToolError::CommandFailed(format!("failed to execute adb: {error}"))) -} - -fn output_to_text(label: &str, output: Output) -> Result { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Ok(match (stdout.is_empty(), stderr.is_empty()) { - (true, true) => format!("{label} completed successfully"), - (false, true) => stdout, - (true, false) => stderr, - (false, false) => format!("{stdout}\n{stderr}"), - }); - } - - Err(McpToolError::CommandFailed(command_failure(label, output))) -} - -fn command_failure(label: &str, output: Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - format!( - "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", - output.status.code(), - stdout.trim(), - stderr.trim() - ) -} - -fn integer_argument(arguments: &Value, name: &'static str) -> Result { - arguments - .get(name) - .and_then(Value::as_i64) - .ok_or(McpToolError::MissingArgument(name)) -} - -fn optional_integer_argument( - arguments: &Value, - name: &'static str, -) -> Result, McpToolError> { - match arguments.get(name) { - Some(value) => value - .as_i64() - .map(Some) - .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be an integer"))), - None => Ok(None), - } -} - -fn string_argument(arguments: &Value, name: &'static str) -> Result { - arguments - .get(name) - .and_then(Value::as_str) - .map(ToString::to_string) - .ok_or(McpToolError::MissingArgument(name)) -} - -fn optional_string_argument( - arguments: &Value, - name: &'static str, -) -> Result, McpToolError> { - match arguments.get(name) { - Some(value) => value - .as_str() - .map(|text| Some(text.to_string())) - .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be a string"))), - None => Ok(None), - } -} - -fn workspace_path(workspace: &Path, value: &str) -> Result { - let candidate = PathBuf::from(value); - if value.is_empty() - || candidate.is_absolute() - || candidate - .components() - .any(|component| component == Component::ParentDir) - { - return Err(McpToolError::InvalidArgument( - "path must be relative, non-empty, and must not contain '..'".to_string(), - )); - } - - Ok(workspace.join(candidate)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{android_spec, DEFAULT_ANDROID_IMAGE}; - use std::io::Cursor; - - fn test_state() -> McpState { - McpState { - spec: android_spec( - "dg-test", - "docker-git-shared", - "dg-test-android:5555", - DEFAULT_ANDROID_IMAGE, - ) - .expect("valid android spec"), - workspace: PathBuf::from("/workspace"), - adb_probe: false, - allow_install: false, - } - } - - fn frame(value: Value) -> String { - let payload = serde_json::to_string(&value).expect("serializable request"); - format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) - } - - #[test] - fn serves_initialize_and_tools_list_over_framed_stdio() { - let input = format!( - "{}{}", - frame(json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {} - })), - frame(json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - })) - ); - let mut reader = Cursor::new(input.into_bytes()); - let mut output = Vec::new(); - - run_stdio(&mut reader, &mut output, test_state()).expect("stdio server succeeds"); - - let output_text = String::from_utf8(output).expect("valid utf8 output"); - assert!(output_text.contains(SERVER_NAME)); - assert!(output_text.contains("android_status")); - assert!(output_text.contains("android_tap")); - } - - #[test] - fn reports_status_without_adb_when_probe_is_disabled() { - let input = frame(json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "android_status", - "arguments": {} - } - })); - let mut reader = Cursor::new(input.into_bytes()); - let mut output = Vec::new(); - - run_stdio(&mut reader, &mut output, test_state()).expect("status succeeds"); - - let output_text = String::from_utf8(output).expect("valid utf8 output"); - assert!(output_text.contains("\"isError\":false")); - assert!(output_text.contains("adbProbe")); - assert!(output_text.contains("false")); - assert!(output_text.contains("dg-test-android")); - } - - #[test] - fn rejects_workspace_paths_outside_workspace() { - let workspace = PathBuf::from("/workspace"); - - assert!(workspace_path(&workspace, "screenshots/current.png").is_ok()); - assert!(workspace_path(&workspace, "/tmp/outside.png").is_err()); - assert!(workspace_path(&workspace, "../outside.png").is_err()); - assert!(workspace_path(&workspace, "screenshots/../outside.png").is_err()); - } -} diff --git a/packages/container/src/core/templates/dockerfile.ts b/packages/container/src/core/templates/dockerfile.ts index 03f18da9..9037f3b4 100644 --- a/packages/container/src/core/templates/dockerfile.ts +++ b/packages/container/src/core/templates/dockerfile.ts @@ -83,6 +83,7 @@ RUN set -eu; \ rtk gain >/dev/null 2>&1 || true` const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest" +const rustAndroidConnectionRevision = "7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c" const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => config.enableMcpPlaywright @@ -92,20 +93,19 @@ const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => : "" // CHANGE: install the first-party Android MCP module when Android MCP is enabled -// WHY: issue-436 requires a separately proven module instead of an unpinned runtime npx server +// WHY: issue-436 requires a separately proven module instead of an unpinned runtime npx server or vendored source copy // QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" // REF: issue-436 -// SOURCE: n/a +// SOURCE: https://github.com/ProverCoderAI/rust-android-connection/commit/7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c // PURITY: CORE (pure template renderer) const renderDockerfileAndroidRuntime = (config: TemplateConfig): string => config.enableMcpAndroid === true ? `# Android MCP runtime: ADB client + first-party Rust android-connection module. -COPY .docker-git-tools/android-connection /opt/docker-git/tools/android-connection RUN apt-get update \ && apt-get install -y --no-install-recommends android-tools-adb \ && rm -rf /var/lib/apt/lists/* \ && adb --version \ - && cargo install --path /opt/docker-git/tools/android-connection --locked --bins --root /usr/local \ + && cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev ${rustAndroidConnectionRevision} --locked --bins --root /usr/local \ && /usr/local/bin/docker-git-android-connection --version \ && /usr/local/bin/android-connection --version` : "" diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index dbd419a3..29f3599a 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -1176,11 +1176,13 @@ describe("renderDockerCompose", () => { const enabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: true })) const disabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: false })) - expect(enabled).toContain("COPY .docker-git-tools/android-connection") expect(enabled).toContain("android-tools-adb") - expect(enabled).toContain("cargo install --path /opt/docker-git/tools/android-connection") + expect(enabled).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c --locked --bins --root /usr/local" + ) expect(enabled).toContain("/usr/local/bin/docker-git-android-connection --version") expect(enabled).toContain("/usr/local/bin/android-connection --version") + expect(enabled).not.toContain(".docker-git-tools/android-connection") expect(disabled).not.toContain(".docker-git-tools/android-connection") expect(disabled).not.toContain("android-tools-adb") }) diff --git a/packages/lib/src/shell/android-connection-source.ts b/packages/lib/src/shell/android-connection-source.ts deleted file mode 100644 index 063f5fca..00000000 --- a/packages/lib/src/shell/android-connection-source.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" -import type * as Path from "@effect/platform/Path" -import { Effect } from "effect" - -import { resolveWorkspaceRoot } from "./workspace-root.js" - -const androidConnectionToolRelativePath = ".docker-git-tools/android-connection" - -const ensureParentDir = ( - path: Path.Path, - fs: FileSystem.FileSystem, - filePath: string -) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) - -const resolveFileUrlPath = (fileUrl: string): string => { - const url = new URL(fileUrl) - return url.protocol === "file:" ? decodeURIComponent(url.pathname) : fileUrl -} - -const shouldSkipAndroidConnectionEntry = (entry: string): boolean => entry === "target" || entry === ".git" - -const copyTextFile = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const contents = yield* _(fs.readFileString(sourcePath)) - yield* _(ensureParentDir(path, fs, targetPath)) - yield* _(fs.writeFileString(targetPath, contents)) - }) - -const copyTextDirectoryEntry = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const info = yield* _(fs.stat(sourcePath)) - if (info.type === "Directory") { - yield* _(copyTextDirectory(fs, path, sourcePath, targetPath)) - return - } - if (info.type === "File") { - yield* _(copyTextFile(fs, path, sourcePath, targetPath)) - } - }) - -const copyTextDirectory = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(fs.makeDirectory(targetPath, { recursive: true })) - const entries = yield* _(fs.readDirectory(sourcePath)) - for (const entry of entries) { - if (shouldSkipAndroidConnectionEntry(entry)) { - continue - } - yield* _( - copyTextDirectoryEntry( - fs, - path, - path.join(sourcePath, entry), - path.join(targetPath, entry) - ) - ) - } - }) - -const androidConnectionSourceCandidates = ( - path: Path.Path, - workspaceRoot: string -): ReadonlyArray => [ - path.join(workspaceRoot, "crates", "android-connection"), - path.join( - path.dirname(resolveFileUrlPath(import.meta.url)), - "..", - "..", - "..", - "..", - "crates", - "android-connection" - ) -] - -const firstExistingDirectory = ( - fs: FileSystem.FileSystem, - candidates: ReadonlyArray -): Effect.Effect => - Effect.gen(function*(_) { - for (const candidate of candidates) { - const isExists = yield* _(fs.exists(candidate)) - if (!isExists) { - continue - } - const info = yield* _(fs.stat(candidate)) - if (info.type === "Directory") { - return candidate - } - } - return null - }) - -// CHANGE: provision the first-party Android MCP Rust source into the Docker build context -// WHY: the generated Dockerfile installs android-connection with cargo install --path --locked -// QUOTE(ТЗ): "Сперва нужно отдельно реализовать сам модуль и доказать что он работает" -// REF: issue-436 -// SOURCE: n/a -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: enabled Android MCP builds from the same audited crate source as local tests -// COMPLEXITY: O(n) where n = |android_connection_source_files| -export const provisionDockerGitAndroidConnectionSource = ( - fs: FileSystem.FileSystem, - path: Path.Path, - baseDir: string -): Effect.Effect => - Effect.gen(function*(_) { - const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) - const sourcePath = yield* _( - firstExistingDirectory( - fs, - androidConnectionSourceCandidates(path, workspaceRoot) - ) - ) - if (sourcePath === null) { - yield* _( - Effect.dieMessage( - "android-connection source not found; expected crates/android-connection in the docker-git workspace" - ) - ) - return - } - - yield* _( - copyTextDirectory( - fs, - path, - sourcePath, - path.join(baseDir, androidConnectionToolRelativePath) - ) - ) - }) diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 98c475fd..3d50a423 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -11,7 +11,6 @@ import { withDefaultResourceLimitIntent } from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" -import { provisionDockerGitAndroidConnectionSource } from "./android-connection-source.js" import { resolveDockerEnvValue } from "./docker-auth.js" import { FileExistsError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -218,15 +217,11 @@ const provisionDockerGitSessionSyncTool = ( const provisionDockerGitBuildContext = ( fs: FileSystem.FileSystem, path: Path.Path, - baseDir: string, - config: TemplateConfig + baseDir: string ): Effect.Effect => Effect.gen(function*(_) { yield* _(provisionDockerGitScripts(fs, path, baseDir)) yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) - if (config.enableMcpAndroid === true) { - yield* _(provisionDockerGitAndroidConnectionSource(fs, path, baseDir)) - } }) // CHANGE: write generated docker-git files to disk @@ -282,7 +277,7 @@ export const writeProjectFiles = ( } } - yield* _(provisionDockerGitBuildContext(fs, path, baseDir, normalizedConfig)) + yield* _(provisionDockerGitBuildContext(fs, path, baseDir)) return created }) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index 11cdf177..5fbdcebe 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -254,7 +254,9 @@ describe("applyProjectFiles", () => { expect(composeAfter).toContain("dg-test-android") const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) - expect(dockerfileAfter).toContain("cargo install --path /opt/docker-git/tools/android-connection") + expect(dockerfileAfter).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c --locked --bins --root /usr/local" + ) const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) expect(configAfter).toContain('"cpuLimit": "2"') diff --git a/packages/lib/tests/usecases/mcp-android.test.ts b/packages/lib/tests/usecases/mcp-android.test.ts index b40fd6a3..6a4fdbdc 100644 --- a/packages/lib/tests/usecases/mcp-android.test.ts +++ b/packages/lib/tests/usecases/mcp-android.test.ts @@ -127,16 +127,11 @@ describe("enableMcpAndroidProjectFiles", () => { const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) expect(dockerfileAfter).toContain("android-tools-adb") - expect(dockerfileAfter).toContain("cargo install --path /opt/docker-git/tools/android-connection") - expect(dockerfileAfter).toContain("/usr/local/bin/android-connection --version") - - const androidConnectionCargoToml = path.join( - outDir, - ".docker-git-tools", - "android-connection", - "Cargo.toml" + expect(dockerfileAfter).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c --locked --bins --root /usr/local" ) - expect(yield* _(fs.exists(androidConnectionCargoToml))).toBe(true) + expect(dockerfileAfter).toContain("/usr/local/bin/android-connection --version") + expect(dockerfileAfter).not.toContain(".docker-git-tools/android-connection") const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText)))