diff --git a/app/src/debug/res/values/leakcanary.xml b/app/src/debug/res/values/leakcanary.xml
new file mode 100644
index 0000000000..51f168539d
--- /dev/null
+++ b/app/src/debug/res/values/leakcanary.xml
@@ -0,0 +1,9 @@
+
+
+
+ false
+
diff --git a/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md b/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md
new file mode 100644
index 0000000000..98d0b62041
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md
@@ -0,0 +1,578 @@
+# CodeOnTheGo MCP Server (hello world) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stand up a host-side MCP server reachable over HTTP that exposes exactly one tool, `ping`, and prove the transport end to end with a real MCP client.
+
+**Architecture:** A standalone Kotlin/JVM Gradle build at `mcp/`, invisible to the root Android build. A Ktor CIO embedded server binds `127.0.0.1` and mounts the MCP SDK's Streamable HTTP route at `/mcp`. Server construction lives in a factory function separate from `main()` so tests can drive the real server over the real transport without invoking the entrypoint.
+
+**Tech Stack:** Kotlin 2.4.10, Java 17, Gradle 8.14.4, `io.modelcontextprotocol:kotlin-sdk-server:0.15.0`, Ktor 3.5.1 (CIO engine), kotlin.test.
+
+**Spec:** `docs/superpowers/specs/2026-08-10-mcp-server-design.md`
+**Ticket:** ADFA-5083
+
+---
+
+## Global Constraints
+
+- **Kotlin must be >= 2.4.0.** `kotlin-sdk-server:0.15.0` is compiled against `kotlin-stdlib 2.4.0`; a 2.3.x compiler rejects its metadata. This plan pins **2.4.10** (latest stable). The root repo's catalog pins 2.3.0 — irrelevant, this is a separate build.
+- **Java 17** everywhere (`BuildConfig.JAVA_VERSION`, `CONTRIBUTING.md`).
+- **Every Gradle invocation runs under flox, launched from the repo root.** The bare shell has JDK 21; flox supplies JDK 17. The env's `on-activate` hook aborts if activated from anywhere but the repo root, so `flox activate -d ../flox/local` from inside `mcp/` **fails**. The working form is:
+
+ ```bash
+ cd /Users/eisen/src/CodeOnTheGo
+ flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew '
+ ```
+- **Tabs, LF line endings.** Root Spotless reaches into `mcp/` — its Kotlin target is `fileTree(rootDir)` matching `**/src/*/kotlin/**/*.kt`, and `kotlinGradle` matches `**/*.gradle.kts`. Nothing excludes top-level standalone dirs. `spotlessApply` runs from the **repo root**, never from `mcp/`.
+- **Do not modify** the root `settings.gradle.kts`, `gradle/libs.versions.toml`, or `.mcp.json`. `mcp/` stays absent from the root build, and its dependencies stay out of the shared catalog.
+- **Bind `127.0.0.1` only.** Never `0.0.0.0` — this is an unauthenticated tool server.
+- **No comments restating what the code says.** No separator/banner comments. ASCII only in code and code comments (`->` not the arrow glyph).
+- Branch is `ADFA-5083-mcp`, already pushed. PRs target `stage`.
+
+### Verified API reference (from the 0.15.0 jars, not the README)
+
+Signatures confirmed via `javap` against `kotlin-sdk-server-jvm-0.15.0.jar`, then verified by compiling against them:
+
+- **The tool handler is `suspend ClientConnection.(CallToolRequest) -> CallToolResult`** — `ClientConnection` is the **receiver**, and there is exactly one parameter. `javap` reports this as `Function3` because a Kotlin receiver is compiled to a leading JVM argument; do not read that as two lambda parameters. A no-argument tool is written `{ _ -> CallToolResult(...) }`.
+- `Server(serverInfo: Implementation, options: ServerOptions, ...)`
+- `Implementation(name: String, version: String, title: String = ..., ...)`
+- `ServerOptions(capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = ..., ...)`
+- `ServerCapabilities(tools: ServerCapabilities.Tools? = null, ...)`; `ServerCapabilities.Tools(listChanged: Boolean?)`
+- `ToolSchema(schema: String = ..., properties: JsonObject, required: List? = null, ...)`
+- `Server.addTool(name, description, inputSchema, title, outputSchema, annotations, execution, meta, handler)` — all but `name` and the handler have defaults; use named arguments.
+- `CallToolResult(content: List, isError: Boolean? = null, ...)`; `TextContent(text: String, ...)`
+- `mcpStreamableHttp` is an extension on **`Application`** (so it goes directly inside the `embeddedServer { }` lambda, not inside a `routing { }` block): `Application.mcpStreamableHttp(path: String = "/mcp", ..., block: (RoutingContext) -> Server)`
+- Client side: `HttpClient.mcpStreamableHttpTransport(url: String, ...): StreamableHttpClientTransport`, `Client(Implementation, ClientOptions = ...)`, `Client.connect(Transport)`, `Client.listTools(): ListToolsResult` (`.tools`), `Client.callTool(name: String, arguments: Map): CallToolResult`
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `mcp/settings.gradle.kts` | Names the standalone build `cogo-mcp`. Nothing else. |
+| `mcp/build.gradle.kts` | Kotlin JVM + application plugin, Java 17, the four dependencies. |
+| `mcp/gradle/wrapper/*`, `mcp/gradlew`, `mcp/gradlew.bat` | Gradle 8.14.4 wrapper, copied from the repo root. |
+| `mcp/.gitignore` | `build/`, `.gradle/`. |
+| `mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt` | `cogoMcpServer(): Server` - builds the MCP server and registers tools. No transport, no I/O. This is the unit tests construct. |
+| `mcp/src/main/kotlin/com/itsaky/androidide/mcp/Main.kt` | `main(args)` - parses the port, starts Ktor, mounts the MCP route. Wiring only. |
+| `mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt` | Smoke test: the SDK resolves and its metadata is readable by this compiler. |
+| `mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt` | End-to-end: real MCP client, real Streamable HTTP, initialize -> tools/list -> tools/call. |
+| `mcp/README.md` | How to run it, how to register it. |
+
+The `CogoMcpServer.kt` / `Main.kt` split is the one structural decision here. It exists so `PingTest` can mount the identical server the entrypoint mounts, without `main()`'s `wait = true` blocking the test thread. As tools accumulate, `CogoMcpServer.kt` splits by tool group and `Main.kt` never changes.
+
+---
+
+### Task 1: Standalone Gradle build that resolves the SDK
+
+Isolates the single riskiest thing in this plan — the Kotlin 2.4.0 metadata floor — so that if it breaks, the failure is unambiguous and unmixed with protocol problems.
+
+**Files:**
+- Create: `mcp/settings.gradle.kts`
+- Create: `mcp/build.gradle.kts`
+- Create: `mcp/.gitignore`
+- Create: `mcp/gradle/wrapper/gradle-wrapper.properties`, `mcp/gradle/wrapper/gradle-wrapper.jar`, `mcp/gradlew`, `mcp/gradlew.bat` (copied)
+- Test: `mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: a working `mcp/` Gradle build with `io.modelcontextprotocol:kotlin-sdk-server:0.15.0`, `io.ktor:ktor-server-cio:3.5.1`, `io.modelcontextprotocol:kotlin-sdk-client:0.15.0` (test), `io.ktor:ktor-client-cio:3.5.1` (test), and `kotlin("test")` on the classpath. There is **no** `ktor-client-sse` artifact — the client SSE plugin ships inside `ktor-client-core`, which arrives transitively via `kotlin-sdk-client`. `application { mainClass = "com.itsaky.androidide.mcp.MainKt" }`.
+
+- [ ] **Step 1: Copy the Gradle wrapper from the repo root**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+mkdir -p mcp/gradle/wrapper mcp/src/main/kotlin/com/itsaky/androidide/mcp mcp/src/test/kotlin/com/itsaky/androidide/mcp
+cp gradle/wrapper/gradle-wrapper.jar gradle/wrapper/gradle-wrapper.properties mcp/gradle/wrapper/
+cp gradlew gradlew.bat mcp/
+chmod +x mcp/gradlew
+grep distributionUrl mcp/gradle/wrapper/gradle-wrapper.properties
+```
+
+Expected: `distributionUrl=...gradle-8.14.4-all.zip`
+
+- [ ] **Step 2: Write `mcp/settings.gradle.kts`**
+
+Tabs, not spaces.
+
+```kotlin
+rootProject.name = "cogo-mcp"
+```
+
+- [ ] **Step 3: Write `mcp/build.gradle.kts`**
+
+Tabs, not spaces. `jvmToolchain(17)` is deliberate: under flox the running JVM is already 17 so it resolves locally, and outside flox it fails with an explicit toolchain error rather than silently compiling against JDK 21.
+
+```kotlin
+plugins {
+ kotlin("jvm") version "2.4.10"
+ application
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation("io.modelcontextprotocol:kotlin-sdk-server:0.15.0")
+ implementation("io.ktor:ktor-server-cio:3.5.1")
+
+ // The client SSE plugin ships inside ktor-client-core, which arrives via
+ // kotlin-sdk-client. There is no separate ktor-client-sse artifact.
+ testImplementation(kotlin("test"))
+ testImplementation("io.modelcontextprotocol:kotlin-sdk-client:0.15.0")
+ testImplementation("io.ktor:ktor-client-cio:3.5.1")
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+application {
+ mainClass.set("com.itsaky.androidide.mcp.MainKt")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+```
+
+- [ ] **Step 4: Write `mcp/.gitignore`**
+
+```
+build/
+.gradle/
+```
+
+- [ ] **Step 5: Write the failing test**
+
+`mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt` — tabs.
+
+```kotlin
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.Implementation
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SdkResolutionTest {
+ @Test
+ fun `sdk types load under this kotlin version`() {
+ val info = Implementation(name = "cogo-mcp", version = "0.1.0")
+ assertEquals("cogo-mcp", info.name)
+ assertEquals("0.1.0", info.version)
+ }
+}
+```
+
+- [ ] **Step 6: Run the test**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- bash -c "cd mcp && ./gradlew test --tests '*SdkResolutionTest*'"
+```
+
+Expected: **PASS**. This test has no red phase — it asserts the build resolves, and a build that cannot resolve fails to compile rather than failing an assertion.
+
+If it fails with `Class 'Implementation' was compiled with an incompatible version of Kotlin`, the Kotlin version is below the 2.4.0 floor — check the `kotlin("jvm") version` string. If it fails with a toolchain error, the shell is not inside flox.
+
+- [ ] **Step 7: Confirm the root build is untouched**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+git status --short settings.gradle.kts gradle/libs.versions.toml .mcp.json
+```
+
+Expected: **no output**.
+
+- [ ] **Step 8: Format and commit**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- ./gradlew spotlessApply
+git add mcp/
+git commit -m "ADFA-5083: Standalone Gradle build for the MCP server
+
+Kotlin 2.4.10 is a floor, not a preference: kotlin-sdk-server 0.15.0 ships
+stdlib 2.4.0 metadata that a 2.3.x compiler rejects. Standalone build keeps
+that off the root catalog entirely."
+```
+
+---
+
+### Task 2: The ping tool, proven over real Streamable HTTP
+
+**Files:**
+- Create: `mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt`
+- Create: `mcp/src/main/kotlin/com/itsaky/androidide/mcp/Main.kt`
+- Test: `mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt`
+
+**Interfaces:**
+- Consumes: the Task 1 build and its classpath.
+- Produces:
+ - `fun cogoMcpServer(): Server` in `com.itsaky.androidide.mcp` - returns a configured `Server` with the `ping` tool registered. Takes no arguments; starts no transport.
+ - `const val DEFAULT_PORT: Int = 3000` in `com.itsaky.androidide.mcp`
+ - `fun main(args: Array)` in `Main.kt` (compiled class `com.itsaky.androidide.mcp.MainKt`)
+
+- [ ] **Step 1: Write the failing test**
+
+`mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt` — tabs. Note `port = 0` for an ephemeral port, then `resolvedConnectors()` to discover what was actually bound; hardcoding 3000 in a test makes it fail whenever a real server is running.
+
+```kotlin
+package com.itsaky.androidide.mcp
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.sse.SSE
+import io.modelcontextprotocol.kotlin.sdk.client.Client
+import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttpTransport
+import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
+import io.modelcontextprotocol.kotlin.sdk.types.Implementation
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import io.ktor.client.engine.cio.CIO as ClientCIO
+import io.ktor.server.cio.CIO as ServerCIO
+import io.ktor.server.engine.embeddedServer
+
+class PingTest {
+ private fun withConnectedClient(block: suspend (Client) -> T): T =
+ runBlocking {
+ val engine =
+ embeddedServer(ServerCIO, host = "127.0.0.1", port = 0) {
+ mcpStreamableHttp { cogoMcpServer() }
+ }.start(wait = false)
+ try {
+ val port = engine.engine.resolvedConnectors().first().port
+ val http = HttpClient(ClientCIO) { install(SSE) }
+ try {
+ val client = Client(Implementation(name = "cogo-mcp-test", version = "0.1.0"))
+ client.connect(http.mcpStreamableHttpTransport("http://127.0.0.1:$port/mcp"))
+ block(client)
+ } finally {
+ http.close()
+ }
+ } finally {
+ engine.stop(gracePeriodMillis = 0, timeoutMillis = 2000)
+ }
+ }
+
+ @Test
+ fun `handshake reports the server identity`() =
+ withConnectedClient { client ->
+ assertEquals("cogo-mcp", client.serverVersion?.name)
+ }
+
+ @Test
+ fun `tools list contains exactly ping`() =
+ withConnectedClient { client ->
+ val tools = client.listTools().tools
+ assertEquals(listOf("ping"), tools.map { it.name })
+ }
+
+ @Test
+ fun `calling ping returns pong`() =
+ withConnectedClient { client ->
+ val result = client.callTool(name = "ping", arguments = emptyMap())
+ val text = result.content.filterIsInstance().single().text
+ assertEquals("pong", text)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- bash -c "cd mcp && ./gradlew test --tests '*PingTest*'"
+```
+
+Expected: **compilation failure**, `Unresolved reference: cogoMcpServer`. That is the correct red phase — the test names a function that does not exist yet.
+
+- [ ] **Step 3: Write `CogoMcpServer.kt`**
+
+The handler is a lambda with `ClientConnection` as receiver and `CallToolRequest` as its single parameter; `ping` uses neither.
+
+```kotlin
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.server.Server
+import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.Implementation
+import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
+import kotlinx.serialization.json.JsonObject
+
+const val SERVER_NAME = "cogo-mcp"
+const val SERVER_VERSION = "0.1.0"
+
+fun cogoMcpServer(): Server {
+ val server =
+ Server(
+ serverInfo = Implementation(name = SERVER_NAME, version = SERVER_VERSION),
+ options =
+ ServerOptions(
+ capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)),
+ ),
+ )
+
+ // Handler is suspend ClientConnection.(CallToolRequest) -> CallToolResult: the
+ // ClientConnection is the receiver, not a parameter. ping uses neither.
+ server.addTool(
+ name = "ping",
+ description = "Health check. Returns pong.",
+ inputSchema = ToolSchema(properties = JsonObject(emptyMap())),
+ ) { _ ->
+ CallToolResult(content = listOf(TextContent("pong")))
+ }
+
+ return server
+}
+```
+
+- [ ] **Step 4: Write `Main.kt`**
+
+```kotlin
+package com.itsaky.androidide.mcp
+
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.embeddedServer
+import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
+
+const val DEFAULT_PORT = 3000
+
+fun main(args: Array) {
+ val port = args.firstOrNull()?.toIntOrNull() ?: DEFAULT_PORT
+
+ // Loopback only: this server is unauthenticated.
+ embeddedServer(CIO, host = "127.0.0.1", port = port) {
+ mcpStreamableHttp { cogoMcpServer() }
+ }.start(wait = true)
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- bash -c "cd mcp && ./gradlew test"
+```
+
+Expected: **4 tests pass** (1 from `SdkResolutionTest`, 3 from `PingTest`).
+
+Two known failure modes, both with a determinate fix:
+- `NoTransformationFoundException` or a hang on `connect` — the Ktor **client** needs the SSE plugin, which the test installs via `install(SSE)`. The plugin class lives in `ktor-client-core` (arriving transitively through `kotlin-sdk-client`); do not try to add `io.ktor:ktor-client-sse`, which does not exist.
+- A 404 on `/mcp` — the route path default differs from `/mcp`. Pass it explicitly: `mcpStreamableHttp(path = "/mcp") { cogoMcpServer() }`.
+
+- [ ] **Step 6: Verify the server runs for real**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- bash -c "cd mcp && ./gradlew run" &
+sleep 20
+curl -sS -i -X POST http://127.0.0.1:3000/mcp \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json, text/event-stream' \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
+```
+
+Expected: HTTP 200 with a JSON-RPC result naming `cogo-mcp`, and an `Mcp-Session-Id` response header. Stop the background server afterward (`kill %1`).
+
+- [ ] **Step 7: Format and commit**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- ./gradlew spotlessApply
+git add mcp/
+git commit -m "ADFA-5083: Add the ping tool and prove the transport end to end
+
+PingTest drives the real MCP client over real Streamable HTTP - initialize,
+tools/list, tools/call - rather than calling the handler directly, since the
+transport is the only thing this PR actually adds.
+
+The tool handler takes (ClientConnection, CallToolRequest); the SDK README's
+single-parameter form is ahead of the 0.15.0 release and does not compile."
+```
+
+---
+
+### Task 3: Documentation and ship
+
+**Files:**
+- Create: `mcp/README.md`
+
+**Interfaces:**
+- Consumes: the working server from Task 2.
+- Produces: nothing consumed by later tasks.
+
+- [ ] **Step 1: Write `mcp/README.md`**
+
+````markdown
+# cogo-mcp
+
+A host-side MCP server for driving Code On The Go from an AI coding agent.
+Runs on the development machine, not on the device. Ticket: ADFA-5083.
+
+Right now it exposes exactly one tool, `ping`. That is deliberate - this is
+scaffolding that proves the transport. adb-backed tools land incrementally.
+
+## Run
+
+The flox environment's `on-activate` hook aborts unless it is activated from
+the repo root, so activate there and `cd` in afterwards:
+
+```bash
+# from the repo root
+flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew run'
+```
+
+Listens on `http://127.0.0.1:3000/mcp`. Pass a different port as the first
+argument: `./gradlew run --args 8080`.
+
+Loopback only, and no TLS - there is no network hop to intercept. Binding a
+non-loopback interface would require both TLS and authentication first.
+
+## Test
+
+```bash
+# from the repo root
+flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew test'
+```
+
+## Register with an MCP client
+
+Not registered automatically. `.mcp.json` is committed and shared, and an
+`http` entry pointing at a process nobody started makes Claude Code report a
+connection failure at startup for every developer on the team. Add it locally
+once you are actually using it:
+
+```json
+{
+ "mcpServers": {
+ "cogo": { "type": "http", "url": "http://127.0.0.1:3000/mcp" }
+ }
+}
+```
+
+## Notes for contributors
+
+- This is a **standalone Gradle build**. It is absent from the root
+ `settings.gradle.kts`, and its dependencies are declared inline rather than
+ in `gradle/libs.versions.toml` - the same pattern as `apk-viewer-plugin/`.
+- **Kotlin 2.4.10 is a floor.** `kotlin-sdk-server:0.15.0` ships
+ `kotlin-stdlib 2.4.0` metadata that the root catalog's 2.3.0 compiler
+ rejects. Do not "align" this with the root version.
+- Root Spotless **does** format this directory. Use tabs, and run
+ `./gradlew spotlessApply` from the **repo root**, not from here.
+````
+
+- [ ] **Step 2: Full verification from a clean build**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- bash -c "cd mcp && ./gradlew clean test"
+cd /Users/eisen/src/CodeOnTheGo
+flox activate -d flox/local -- ./gradlew spotlessCheck
+```
+
+Expected: tests pass, `spotlessCheck` passes. Do not proceed past a failure here.
+
+If `spotlessCheck` reports violations in files under `mcp/build/`, the cause is known: the root Spotless config derives its build-output exclusions from the root build's `allprojects`, and `mcp` is not one of them, so `mcp/build/` is not pruned from the walk. Fix by running `./gradlew clean` in `mcp/` before `spotlessCheck` from the root. Do **not** add `mcp/**` to the root `commonTargetExcludes` — that would exempt the source too.
+
+- [ ] **Step 3: Confirm the root build is still untouched**
+
+```bash
+cd /Users/eisen/src/CodeOnTheGo
+git diff --stat origin/stage...HEAD -- settings.gradle.kts gradle/libs.versions.toml .mcp.json
+git diff --stat origin/stage...HEAD | tail -3
+```
+
+Expected: the first command prints **nothing**; the second shows only `docs/` and `mcp/` files.
+
+- [ ] **Step 4: Commit and push**
+
+```bash
+git add mcp/README.md
+git commit -m "ADFA-5083: Document how to run and register the MCP server"
+git push
+```
+
+- [ ] **Step 5: Open the PR into stage**
+
+Write the body to a tempfile first — CLAUDE.md forbids inline heredocs for anything with special characters. Write exactly this to `/tmp/pr-body.md` using the Write tool:
+
+```markdown
+Stands up the smallest MCP server that proves anything, so the useful tools can
+land incrementally on top of a transport that already works.
+
+## What this is
+
+A host-side Kotlin/JVM MCP server in a standalone Gradle build at `mcp/`,
+serving Streamable HTTP on `127.0.0.1:3000/mcp`. It exposes exactly one tool,
+`ping`. That is the whole scope.
+
+Host-side rather than in-APK: nothing ships to the device, so this carries no
+app risk and iterates independently of releases.
+
+## Review notes
+
+- **The root build is untouched.** `settings.gradle.kts`,
+ `gradle/libs.versions.toml`, and `.mcp.json` are unchanged. `mcp/` is
+ invisible to the Android build, the same way `apk-viewer-plugin/` is.
+- **Kotlin 2.4.10 here is a floor, not drift.** `kotlin-sdk-server:0.15.0`
+ ships `kotlin-stdlib 2.4.0` metadata that the catalog's 2.3.0 compiler
+ rejects outright. The standalone layout is what makes that harmless.
+- **`.mcp.json` is deliberately not modified.** It is committed and shared; an
+ `http` entry aimed at a process nobody started makes Claude Code report a
+ connection failure at startup for every developer. `mcp/README.md` documents
+ the snippet for local use.
+- **No TLS.** Loopback only, so there is no hop to intercept. Binding a
+ non-loopback interface would require TLS and authentication first, and gets
+ its own ticket.
+- **Tests drive the real protocol.** `PingTest` starts the server on an
+ ephemeral port and connects with the SDK's own MCP client over Streamable
+ HTTP - initialize, tools/list, tools/call. Calling the handler directly would
+ prove nothing about the only thing this PR adds.
+
+## Next
+
+adb-backed tools, then CoGo-specific awareness (IDE screens, project and build
+state). Open question worth settling first: a generic `android-mcp-server`
+already exists, so the case for a bespoke one rests on CoGo-awareness rather
+than a generic view hierarchy.
+
+Design spec: `docs/superpowers/specs/2026-08-10-mcp-server-design.md`
+Plan: `docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md`
+```
+
+```bash
+gh pr create --base stage \
+ --title "ADFA-5083: Minimal HTTP MCP server (hello world)" \
+ --body-file /tmp/pr-body.md
+```
+
+- [ ] **Step 6: Comment on the ticket**
+
+```bash
+jira issue comment add ADFA-5083 "PR opened into stage. Hello-world server is green: initialize, tools/list and tools/call ping all verified over real Streamable HTTP. Next up: adb-backed tools."
+```
+
+---
+
+## Definition of Done
+
+- [ ] `flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew run'` from the repo root serves `127.0.0.1:3000/mcp`
+- [ ] All 4 tests pass from a clean build
+- [ ] Root `./gradlew spotlessCheck` passes
+- [ ] `mcp/README.md` documents run, test, and registration
+- [ ] Root `settings.gradle.kts`, `gradle/libs.versions.toml`, and `.mcp.json` are unchanged
+- [ ] PR open against `stage`, ticket commented
+
+## Deliberately Not In This Plan
+
+adb-backed tools, CoGo-specific awareness (IDE screens, project/build state), TLS, authentication, CI wiring for standalone directories, and any packaging beyond `./gradlew run`. Each is a follow-up.
+
+**One open question for the team, from the spec:** a generic `android-mcp-server` is already registered in `~/.claude.json` with `get_ui_tree`, `tap_element`, and `screenshot`. The justification for a bespoke server is CoGo-awareness rather than a generic view hierarchy. Settle that before PR #2 defines any real tool.
diff --git a/docs/superpowers/specs/2026-08-10-mcp-server-design.md b/docs/superpowers/specs/2026-08-10-mcp-server-design.md
new file mode 100644
index 0000000000..68a608575e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-mcp-server-design.md
@@ -0,0 +1,169 @@
+# ADFA-5083: CodeOnTheGo MCP server - hello world
+
+**Status:** design approved, not implemented
+**Ticket:** ADFA-5083 - "It is difficult for AI coding agents to navigate the app using low level adb commands. Let's give them a tool to be more successful."
+
+## Goal
+
+Stand up the smallest possible MCP server over HTTP, prove the transport end to end, and merge it. Everything of actual value gets added incrementally on top. This PR is scaffolding, deliberately.
+
+## Decisions
+
+| Decision | Choice | Why |
+|---|---|---|
+| Where the server runs | Host-side (dev machine), not in the APK | Zero APK risk, iterates independently of app releases, works against any build. Trades away privileged access to IDE internals - revisit only if a future tool actually needs it. |
+| Language | Kotlin/JVM | Matches the repo's primary language. Official MCP Kotlin SDK exists and supports Streamable HTTP first-class. |
+| Where the code lives | Standalone Gradle build at `mcp/`, absent from the root `settings.gradle.kts` | Exact pattern of the existing `apk-viewer-plugin/` and `markdown-preview-plugin/`. Keeps Ktor and kotlinx.serialization out of `:app`'s classpath and out of `gradle/libs.versions.toml`. |
+| Transport | Streamable HTTP, `http://127.0.0.1:/mcp` | Current MCP standard. SSE is deprecated and exists only for backward compatibility. |
+| TLS | None | Loopback only - there is no network hop to intercept, and TLS would cost a self-signed cert plus per-client trust config for no security gain. TLS becomes mandatory the day the server binds a non-loopback interface; that is a separate ticket. |
+| Tool surface | One tool, `ping` | Isolates the transport and handshake from every other concern. If it fails, the cause is unambiguous. |
+
+### Premise worth stating
+
+A generic `android-mcp-server` (`npx -y android-mcp-server`) is already registered in the user-scope `~/.claude.json` with `get_ui_tree`, `tap_element`, `screenshot`, and `scroll_to_element`. It overlaps ADFA-5083's stated goal.
+
+The justification for a bespoke server is that it can be **CoGo-aware** - it can know the IDE's screens, project state, and build status rather than treating the app as an opaque view hierarchy. That is the differentiator, and it does not exist in this PR. It should be written into the ticket before PR #2 defines any real tool.
+
+## Architecture
+
+```
+Claude Code (or any MCP client)
+ |
+ | Streamable HTTP, JSON-RPC 2.0
+ v
+http://127.0.0.1:3000/mcp
+ |
+ Ktor CIO embedded server
+ |
+ mcpStreamableHttp { } <- io.modelcontextprotocol:kotlin-sdk-server
+ |
+ Server(serverInfo, options)
+ |
+ tool: ping -> "pong"
+```
+
+Single process, single responsibility. No adb, no device, no state.
+
+## Layout
+
+```
+mcp/
+ settings.gradle.kts rootProject.name = "cogo-mcp"
+ gradle/wrapper/ Gradle 8.14.4 (matches root wrapper)
+ gradlew, gradlew.bat
+ build.gradle.kts kotlin("jvm") + application, Java 17
+ src/main/kotlin/com/itsaky/androidide/mcp/Main.kt
+ src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt
+ README.md how to run, how to register
+ .gitignore
+```
+
+`mcp/` is invisible to the root build. The root `settings.gradle.kts` is not touched.
+
+## Pinned versions
+
+Verified against Maven Central on 2026-08-10, not assumed:
+
+| Artifact | Version | Note |
+|---|---|---|
+| `io.modelcontextprotocol:kotlin-sdk-server` | `0.15.0` | Latest release, published 2026-07-28 |
+| `io.ktor:ktor-server-cio` | `3.5.1` | Matches the SDK's own transitive Ktor; latest is 3.5.2 |
+| Kotlin | `2.4.10` | Latest stable. **Must be >= 2.4.0**: the SDK is built against `kotlin-stdlib 2.4.0`, so a 2.3.0 compiler would reject its metadata. The root repo's 2.3.0 is irrelevant here - separate build. |
+| Java | `17` | Repo-wide standard (`BuildConfig.JAVA_VERSION`, `CONTRIBUTING.md`). Provided by flox; the bare shell has JDK 21. |
+| Gradle | `8.14.4` | Matches the root wrapper |
+
+The SDK does **not** pull a Ktor server engine transitively - the engine must be declared explicitly.
+
+The kotlinx.serialization **compiler plugin is not needed**: hello-world declares no `@Serializable` classes, and the `kotlinx-serialization-json` runtime arrives transitively via `ktor-serialization-kotlinx-json`.
+
+## Behavior
+
+```kotlin
+fun main(args: Array) {
+ val port = args.firstOrNull()?.toIntOrNull() ?: 3000
+
+ val server = Server(
+ serverInfo = Implementation(name = "cogo-mcp", version = "0.1.0"),
+ options = ServerOptions(
+ capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)),
+ ),
+ )
+
+ server.addTool(
+ name = "ping",
+ description = "Health check. Returns pong.",
+ inputSchema = ToolSchema(properties = JsonObject(emptyMap())),
+ ) { CallToolResult(content = listOf(TextContent("pong"))) }
+
+ embeddedServer(CIO, host = "127.0.0.1", port = port) {
+ mcpStreamableHttp { server }
+ }.start(wait = true)
+}
+```
+
+Port is `args[0]`, defaulting to 3000. Host is hardcoded to `127.0.0.1` - binding `0.0.0.0` would expose an unauthenticated tool server to the local network, and there is no reason to.
+
+## Verification
+
+**Test first.** `PingTest` starts the server on an ephemeral port, connects using the SDK's own MCP **client** over Streamable HTTP, and drives the real protocol:
+
+1. `initialize` - handshake succeeds, server reports name `cogo-mcp`
+2. `tools/list` - returns exactly one tool named `ping`
+3. `tools/call ping` - returns text content `"pong"`
+
+Calling the lambda directly would prove nothing about the transport, which is the entire point of this PR.
+
+Manual check, documented in `mcp/README.md`:
+
+```bash
+# from mcp/
+flox activate -d ../flox/local -- ./gradlew run
+```
+
+## Registration
+
+`mcp/README.md` documents the snippet; **PR #1 does not edit the tracked `.mcp.json`.**
+
+```json
+{
+ "mcpServers": {
+ "cogo": { "type": "http", "url": "http://127.0.0.1:3000/mcp" }
+ }
+}
+```
+
+Reason: `.mcp.json` is committed and shared. An `http` entry pointing at a process nobody launched makes Claude Code report a connection failure at startup for every developer on the team. Registering it becomes worthwhile once the server does something worth connecting to.
+
+## Formatting
+
+Root Spotless **does** cover `mcp/` - its Kotlin target is `fileTree(rootDir)` with `**/src/*/kotlin/**/*.kt`, and `kotlinGradle` targets `**/*.gradle.kts`. Neither excludes top-level standalone directories, which is why `apk-viewer-plugin/` and `markdown-preview-plugin/` are already formatted by it.
+
+Consequences: **tabs** for indentation in all `mcp/` Kotlin and `.gradle.kts` sources, ktlint rules apply, and `./gradlew spotlessApply` runs from the **root**, not from `mcp/`.
+
+Markdown is not a Spotless target, so this document is free-form.
+
+## Out of scope
+
+Explicitly deferred, in rough priority order for later PRs:
+
+- Any adb-backed tool (`list_devices`, `screenshot`, `get_ui_tree`, `tap`, `launch_app`)
+- CoGo-specific awareness: IDE screen identification, project/build state, editor contents
+- TLS
+- CI wiring - no workflow builds standalone directories today; `mcp/` is verified locally in PR #1. Worth a follow-up ticket.
+- Authentication - unnecessary while bound to loopback, mandatory the moment it is not
+- Packaging beyond `./gradlew run` (a distributable start script via `installDist`, a daemon, a launcher)
+
+## Risks
+
+1. **SDK API surface.** The `mcpStreamableHttp { }` builder and the `io.modelcontextprotocol.kotlin.sdk.types.*` package paths come from the SDK's README on `main`, which may be ahead of the 0.15.0 release. First implementation step is to compile against 0.15.0 and correct the imports and signatures to whatever that version actually ships. Do not assume the README matches the release.
+2. **Kotlin version floor.** If the 2.4.10 toolchain causes trouble, the fallback is 2.4.0 (the SDK's own stdlib version), not 2.3.0.
+3. **Spotless build-output pruning.** `buildOutputExcludes` is derived from the root build's `allprojects`, so `mcp/build/` is not pruned from the Spotless walk. Low impact - build output does not match `**/src/*/kotlin/**` - but if `spotlessCheck` starts complaining about generated files, that is the cause.
+
+## Definition of done
+
+- [ ] `flox activate -d flox/local -- ./gradlew run` from `mcp/` starts a server on `127.0.0.1:3000`
+- [ ] `PingTest` passes: initialize, tools/list, tools/call all succeed over real Streamable HTTP
+- [ ] Root `./gradlew spotlessCheck` passes
+- [ ] `mcp/README.md` documents run and registration
+- [ ] Root build is unaffected - `settings.gradle.kts` and `gradle/libs.versions.toml` unchanged
+- [ ] PR into `stage` from `ADFA-5083-mcp`
diff --git a/mcp/.gitignore b/mcp/.gitignore
new file mode 100644
index 0000000000..9f2a078806
--- /dev/null
+++ b/mcp/.gitignore
@@ -0,0 +1,2 @@
+build/
+.gradle/
diff --git a/mcp/PRIORITIES.md b/mcp/PRIORITIES.md
new file mode 100644
index 0000000000..e8aecb79fe
--- /dev/null
+++ b/mcp/PRIORITIES.md
@@ -0,0 +1,225 @@
+# cogo-mcp tool priorities
+
+Supersedes `TODO.txt`. Scored 2026-08-11 against a real emulator, not from
+reading the source alone -- several entries changed once measured.
+
+## Status
+
+**Built:** `ping`, `is_cogo_installed`, `cogo_home` (items 6-adjacent), plus the
+top three -- `list_projects` (18), `list_templates` (17), `list_project_files`
+(23). 69 tests.
+
+**Blocked on defects.** A three-lens code review found problems serious enough
+that they outrank every remaining tool below. See "Review defects". Adding tools
+before fixing them multiplies the same mistakes across a wider surface: the
+injection and quoting faults are in the shared command-building idiom, and the
+absent CI means nothing catches a regression on merge.
+
+## Rubric
+
+**Score = 4·Value + 3·Reach + 2·Verify + 2·Unblocks + 1·Safety** (max 60)
+
+### Value (x4) - does this serve the reason the MCP exists?
+
+The MCP exists to let an agent *exercise the IDE*, not to build software. A tool
+that enables testing a feature -- including a destructive one -- scores as value,
+not as risk.
+
+| 5 | Core loop. An agent can do little without it. |
+| 3 | Real workflow, off the critical path. |
+| 1 | Completeness. Rarely needed. |
+
+### Reach (x3) - how much machinery does it take?
+
+Scored *assuming prerequisites are met*; dependencies are the `PREREQ` flag, so
+they are not counted twice.
+
+| 5 | Exported activity, or a pure file/preference operation with no UI at all. |
+| 4 | A single wired keyboard shortcut. |
+| 3 | Shortcut plus a tap or two. |
+| 2 | Multi-step UI within `MainActivity`. |
+| 1 | Long form-filling, or deep inside `EditorActivityKt`. |
+
+### Verify (x2) - can the tool prove it worked?
+
+| 5 | Deterministic output we can assert (a file list). |
+| 4 | `dumpsys` confirms the resumed activity. |
+| 2 | Needs a UI-tree dump -- same activity either way. |
+| 1 | No reliable signal; success would be an assumption. |
+
+### Unblocks (x2)
+
+| 5 | Gates a cluster. | 3 | Unblocks one or two. | 1 | Leaf. |
+
+### Safety (x1, inverted)
+
+Scores **the tool's own effect**, never the screen's potential. A read-only tool
+that opens a dangerous screen is a 5.
+
+| 5 | Read-only. | 3 | Writes recoverable state. | 1 | Destroys user data. |
+
+### Tie-breakers
+
+1. Prefer the cheapest thing that teaches us something new.
+2. Prefer read-only over mutating.
+3. Prefer a natural test seam over device-only verification.
+
+**Deliberately not a dimension:** "similar to something we already built."
+Grouping the six preference screens invites building an abstraction before the
+duplication is real. Score each alone; if a seam emerges after the second or
+third, refactor then.
+
+## Scores
+
+| Score | # | Task | V | R | Ve | U | S | Flags |
+|---|---|---|---|---|---|---|---|---|
+| 58 | 18 | List pre-existing projects | 5 | 5 | 5 | 4 | 5 | |
+| 56 | 23 | List files in current project | 5 | 5 | 5 | 3 | 5 | PREREQ |
+| 54 | 17 | List available project templates | 4 | 5 | 5 | 4 | 5 | |
+| 51 | 19 | Open a specific project | 5 | 3 | 4 | 5 | 4 | |
+| 49 | 6 | Navigate to Main Preferences | 4 | 4 | 4 | 4 | 5 | |
+| 44 | 5 | Navigate to Home Termux | 3 | 5 | 4 | 2 | 5 | |
+| 43 | 1 | Navigate to new project | 4 | 4 | 2 | 3 | 5 | |
+| 43 | 2 | Navigate to open saved project | 4 | 4 | 2 | 3 | 5 | |
+| 42 | 20 | Create a project from a template | 5 | 1 | 4 | 4 | 3 | |
+| 42 | 24 | Save the current project | 4 | 4 | 3 | 2 | 4 | PREREQ |
+| 41 | 16 | Move feedback button | 2 | 5 | 4 | 3 | 4 | setup primitive |
+| 37 | 3 | Navigate to clone a git project | 3 | 4 | 2 | 2 | 5 | |
+| 37 | 4 | Navigate to delete a saved project | 4 | 2 | 2 | 3 | 5 | gates destructive testing |
+| 35 | 25 | Close the current project | 3 | 3 | 4 | 2 | 2 | PREREQ |
+| 34 | 21 | Open project left drawer | 3 | 3 | 2 | 2 | 5 | PREREQ |
+| 34 | 22 | Open project bottom drawer | 3 | 3 | 2 | 2 | 5 | PREREQ |
+| 32 | 13 | Navigate to Plugin Manager | 2 | 3 | 4 | 1 | 5 | |
+| 32 | 15 | Navigate to Main Help | 2 | 3 | 4 | 1 | 5 | |
+| 28 | 7-11 | General / Editor / Build & Run / Terminal / Git preferences | 2 | 3 | 2 | 1 | 5 | |
+| 28 | 12 | Navigate to About Cogo | 1 | 3 | 4 | 1 | 5 | |
+| 28 | 14 | Navigate to Developer Options | 2 | 3 | 2 | 1 | 5 | |
+| -- | 26 | Test FAB drag behaviour | | | | | | not in original list; see item 16 |
+
+**The top three need no UI at all** -- they are `adb shell` reads. Highest value
+and lowest cost at once, which is rare enough to act on before anything that
+drives a screen.
+
+## Measured reachability
+
+`adb shell` (uid 2000) does **not** hold `START_ANY_ACTIVITY` on this emulator.
+Verified by `SecurityException`, not inferred. Only four activities are exported:
+
+- `.activities.SplashActivity`, `.activities.MainActivity`,
+ `.activities.CrashHandlerActivity`, `com.termux.app.TermuxActivity`
+
+Everything else -- Preferences, PluginManager, About, Help, FAQ, TerminalActivity,
+Editor, Onboarding -- is hard-blocked from `am start -n`.
+
+**There is no deep-link tier.** `PreferencesActivity` never calls `getIntent()`;
+no activity accepts a destination extra.
+
+**Keyboard shortcuts are the workaround.** From `MainActivity`: `Ctrl+,`
+preferences, `Ctrl+Alt+T` terminal, `Ctrl+N` new project, `Ctrl+O` open project,
+`Ctrl+Shift+O` clone. In the editor: `Ctrl+S` save. Note `input keyevent` sends
+**no meta state** -- this needs `input keycombination` (API 31+), still unproven
+on-device. Items 1, 2, 3 and 6 all depend on it.
+
+## Corrections to the original list
+
+- **Items 1-4 are fragments, not activities.** They are `SCREEN_*` values on
+ `MainViewModel`, swapped by view visibility inside `MainActivity`.
+- **Item 5 must target `com.termux.app.TermuxActivity`** (exported).
+ `.activities.TerminalActivity` is not exported and will fail.
+- **Item 19 cannot use an intent.** `EditorActivityKt` is non-exported *and*
+ `singleTask` with no `onNewIntent` override, so `PROJECT_PATH` cannot be passed
+ externally even in principle.
+- **Item 4 is not destructive.** It navigates to the delete screen; it deletes
+ nothing.
+
+## Decisions
+
+- **Item 16 is a setup primitive**, not a feature test. It writes
+ `shared_prefs/FabPrefs.xml` (`fab_x_ratio`, `fab_y_ratio`) via `run-as`; the
+ position is re-applied in `onResume`. Its purpose is parking the FAB away from
+ UI under automation -- it is ``d into six layouts and floats over
+ content.
+ - The nine positions use ratios **0.1 / 0.5 / 0.9**, not 0.0 / 0.5 / 1.0.
+ The extremes sit against the `getSafeDraggingBounds()` clamp boundary and
+ closer to system gesture zones.
+ - This bypasses `DraggableTouchListener` entirely, so the drag itself stays
+ untested -- tracked as item 26.
+
+## Review defects
+
+Found 2026-08-11 by three independent reviews (correctness, silent-failure,
+test-quality). Every item was reproduced on `emulator-5554` or by mutating the
+source and re-running the suite -- none is speculative. Fix these before adding
+tools: the faults are in the shared command-building idiom, so each new tool
+copies them.
+
+| Sev | Defect | Where |
+|---|---|---|
+| Critical | **Command injection.** `runAs` wraps its payload in double quotes, and `readMemberCommand` interpolates the archive name and member path -- both device-derived -- unquoted. A crafted `.cgt` executed an arbitrary command on the device during review. Plugins can add archives. | `Templates.kt:62-65`, `CogoDevice.kt:15` |
+| Critical | **`cogo_home` can overwrite every preference.** `cat ... \|\| true` masks a *failed* read as an empty one, and `withAutoOpenDisabled("")` then returns a fresh 3-line document that replaces theme, locale, SDK paths and all. Reported as success. Survives mutation testing: no test catches it, because every fake returns an empty prefs read. | `CogoHome.kt:61-67`, `CogoDevice.kt:19` |
+| Critical | **`list_project_files` reports a project that is not open.** `ide_last_project` is "most recently opened, ever" -- production never writes the sentinel back. After `cogo_home`, the tool confidently describes a project while the IDE sits on its home screen. | `ProjectFiles.kt:34-38` |
+| High | **`SystemAdb.run` throws instead of returning `AdbResult`** when `adb` is not on PATH, bypassing `adbFailure` entirely. | `Adb.kt:37` |
+| High | **No timeout anywhere.** A wedged adb hangs the tool, the request and the agent indefinitely. `cogo_home` makes up to 33 such calls. | `Adb.kt:37-53` |
+| High | **Unreadable projects directory reads as "No projects".** `cd "$dir" \|\| exit 0` cannot be rescued by any exit code; at mode 111 the glob simply fails to expand and stderr is discarded. | `Projects.kt:38-39` |
+| High | **The stderr drain thread is untested.** Removing it passes all 69 tests; 256KB of stderr deadlocks permanently. The test writes 5 bytes -- below any pipe buffer. | `Adb.kt:40-47` |
+| Medium | A stale recorded project path reports "adb failed" when adb worked perfectly. | `ProjectFiles.kt:111` |
+| Medium | An unreadable templates directory reports "not installed yet" *and volunteers onboarding as the cause*. | `Templates.kt:16,68` |
+| Medium | `cogo_home` writes the whole prefs document through argv; measured to break above ~16KB, permanently. | `CogoHome.kt:46-49` |
+| Medium | `resumedActivity` cannot tell "wrong activity" from "could not parse dumpsys", and reports the second as the first. | `CogoHome.kt:98` |
+| Medium | **Nothing runs these tests in CI.** `mcp/` is absent from the root build and from every workflow, so all 69 tests run only by hand. Every gap here is unguarded on merge. | -- |
+| Low | No test asserts any tool `description`, though `ServerDescriptionTest`'s own header and the README both claim it does. | `ServerDescriptionTest.kt` |
+| Low | Nothing pins `host = "127.0.0.1"`, though the README makes a security claim about it. A one-character edit exposes the server to the LAN. | `Main.kt:13` |
+| Low | The "adb shell emits CRLF" comments are false for this adb (measured LF), and the `trim()` calls they justify are dead -- `lineSequence()` already splits CRLF. Meanwhile `withAutoOpenDisabled`, the one parser that does raw string surgery, has **no** CRLF test and grows unboundedly on CRLF input. | five sites |
+
+### The pattern worth remembering
+
+`exitCode` is checked at all 12 call sites. It is nonetheless the wrong signal in
+four of them, because each of those commands was deliberately written to keep the
+*outer* exit status at zero (`|| true`, `|| exit 0`, `unzip -p`). The check is
+real and inspects a value the command guarantees. Guard the inner failure, or
+emit an explicit marker the parser can recognise -- as `listProjectsCommand`
+already does with `NO_PROJECTS_DIR`.
+
+## Known blockers and gotchas
+
+- **`template.json` is not strict JSON.** `{identifier: "APP_NAME"}` has an
+ unquoted key; a strict parser rejects it. Lenient parsing required.
+- **Templates need a set-up device.** `files/home/.cg/templates/` is empty until
+ onboarding completes.
+- **Locale fragility.** Preference rows expose a `key`, but a preference key is
+ not a view resource-id, so UI automation must match on **title text** -- which
+ breaks whenever a translation lands.
+- **Anything using `run-as` needs a debuggable build.**
+
+## What we would ask of the CoGo app
+
+Ranked by leverage. All should be **debug-only** -- an exported navigation
+surface in a release build lets any installed app drive the IDE.
+
+1. **A debug-only exported navigation entry point** taking a destination key.
+ Collapses ~15 items into one mechanism, and the routing tables already exist
+ (`MainViewModel.currentScreen`, the preference keys).
+2. **Publish the current screen where adb can read it.** One line per screen
+ change (`Log.i("CogoNav", "screen=...")`) moves ~10 items from Verify 2 to 5.
+ `MainActivity` hosts 7 fragments and `PreferencesActivity` reuses one fragment
+ class with a hardcoded toolbar title, so `dumpsys` cannot tell them apart.
+3. **Locale-independent identifiers on navigable rows** -- `contentDescription`
+ set to the existing preference key.
+4. **A `skipAutoOpen` boolean extra on `MainActivity`**, honoured for that launch
+ only. Removes `cogo_home`'s need to permanently rewrite a user preference.
+5. **`onNewIntent` on `EditorActivityKt`**, debug-exported, honouring
+ `PROJECT_PATH`.
+6. **An automation mode suppressing the feedback FAB and tooltip overlays**,
+ which intercept coordinate taps.
+7. **Record which project is *currently open*, distinct from the last one ever
+ opened.** This one is not a wish -- it is a defect we inherited.
+ `ide_last_project` is only ever written on open (`MainActivity.kt:407`) and
+ never cleared, so after `cogo_home` the IDE sits on its home screen while the
+ preference still names a project. There is no on-device signal for "nothing is
+ open", so `list_project_files` cannot answer honestly no matter how it is
+ written. Clearing the key on close, or publishing the open project alongside
+ ask 2, fixes it at the source.
+
+If only two were possible: 1 and 2. Reachability plus verifiability is the whole
+problem. Ask 7 is the cheapest of the lot and removes a wrong answer rather than
+an inconvenience.
diff --git a/mcp/README.md b/mcp/README.md
new file mode 100644
index 0000000000..a081825552
--- /dev/null
+++ b/mcp/README.md
@@ -0,0 +1,131 @@
+# cogo-mcp
+
+A host-side MCP server for driving Code On The Go from an AI coding agent.
+Runs on the development machine, not on the device. Ticket: ADFA-5083.
+
+The tool surface is deliberately small and grows one tool at a time.
+
+| Tool | Args | Does |
+|---|---|---|
+| `ping` | none | Returns `pong`. Health check for the transport itself. |
+| `is_cogo_installed` | none | Reports whether `com.itsaky.androidide` is installed on the attached device. |
+| `cogo_home` | none | Brings CoGo to its home screen and confirms it arrived. **Destructive:** force-stops the app and permanently disables auto-open-project. |
+| `list_projects` | none | Valid projects under `/storage/emulated/0/CodeOnTheGoProjects`. |
+| `list_templates` | none | Installed project templates, with descriptions. |
+| `list_project_files` | none | Files in the currently open project, relative to its root. |
+
+The three `list_*` tools drive no UI at all - they are `adb shell` reads. That is
+why they landed first: highest value and lowest cost at once. See
+[PRIORITIES.md](PRIORITIES.md).
+
+`is_cogo_installed` reports an **error**, not `not installed`, when adb itself
+fails. Not knowing is not the same as knowing the app is absent, and collapsing
+the two would make a missing device look like a missing app.
+
+### Why `cogo_home` rewrites a preference
+
+You cannot reach home just by launching the app. `MainActivity.onCreate` calls
+`tryOpenLastProject()`, and `GeneralPreferences.autoOpenProjects` defaults to
+**true**, so the real path is Splash -> Onboarding -> MainActivity -> **Editor**.
+
+Clearing `ide_last_project` is not enough either: `tryOpenLastProject()` falls
+back to `validProjects.maxByOrNull { it.lastModified() }`, so it opens *some*
+project regardless. Only `idepref_general_autoOpenProjects = false` prevents it.
+
+So the tool force-stops the app (a running app holds its preferences in memory
+and would write them back over the edit), reads
+`shared_prefs/com.itsaky.androidide_preferences.xml` via `run-as`, rewrites just
+that one key, relaunches `MainActivity` explicitly, and polls
+`dumpsys activity activities` until the resumed activity is `MainActivity`.
+
+Two consequences worth knowing: it needs a **debuggable build** for `run-as`,
+and the preference change **persists** - the app will not auto-open projects
+again until the user turns it back on.
+
+The XML edit happens in Kotlin (`withAutoOpenDisabled`), not in on-device `sed`,
+specifically so it can be tested. The first version used `sed '/KEY/d'` and
+corrupted the file on its second run by deleting the `")
+
+ // Trailing newline kept consistent with the created-from-scratch document, so
+ // feeding this function its own output is a no-op.
+ return updated.trimEnd() + "\n"
+}
+
+// base64 so the XML survives adb's argv-joining and the device shell intact.
+private fun writePreferencesCommand(xml: String): String {
+ val encoded = Base64.getEncoder().encodeToString(xml.toByteArray())
+ return runAs("mkdir -p shared_prefs && echo $encoded | base64 -d > $COGO_PREFS_PATH")
+}
+
+internal suspend fun cogoHome(
+ adb: Adb,
+ attempts: Int,
+ delayMillis: Long,
+): CallToolResult {
+ // Stop first: a running app holds its preferences in memory and would write
+ // them back over our edit when it exits.
+ adb.shell("am", "force-stop", COGO_PACKAGE).let {
+ if (it.exitCode != 0) return adbFailure(it)
+ }
+ val existingPrefs = adb.shell(readCogoPreferencesCommand())
+ if (existingPrefs.exitCode != 0) {
+ return adbFailure(existingPrefs)
+ }
+ adb.shell(writePreferencesCommand(withAutoOpenDisabled(existingPrefs.stdout))).let {
+ if (it.exitCode != 0) return adbFailure(it)
+ }
+ // Explicit component: debug builds ship a second LAUNCHER activity, so a
+ // category-based launch is ambiguous.
+ adb.shell("am", "start", "-n", MAIN_ACTIVITY).let {
+ if (it.exitCode != 0) return adbFailure(it)
+ }
+
+ var resumed: String? = null
+ repeat(attempts) { attempt ->
+ if (attempt > 0) {
+ delay(delayMillis)
+ }
+ val dump = adb.shell("dumpsys", "activity", "activities")
+ if (dump.exitCode != 0) {
+ return adbFailure(dump)
+ }
+ resumed = resumedActivity(dump.stdout)
+ if (resumed == MAIN_ACTIVITY) {
+ return CallToolResult(
+ content = listOf(TextContent("Code On The Go is on its home screen (MainActivity).")),
+ )
+ }
+ }
+
+ val where = resumed ?: "nothing (the app never reached the foreground)"
+ return CallToolResult(
+ content = listOf(TextContent("Launched Code On The Go, but the foreground activity is $where, not the home screen.")),
+ isError = true,
+ )
+}
+
+private fun resumedActivity(dumpsys: String): String? =
+ dumpsys
+ .lineSequence()
+ .firstOrNull { it.contains("topResumedActivity=") }
+ ?.substringAfter("topResumedActivity=")
+ ?.split(" ", "}")
+ ?.firstOrNull { it.contains("/") }
diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoInstalled.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoInstalled.kt
new file mode 100644
index 0000000000..39ee5f4f9d
--- /dev/null
+++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoInstalled.kt
@@ -0,0 +1,34 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+
+internal fun isCogoInstalled(adb: Adb): CallToolResult {
+ val result = adb.shell("pm", "list", "packages", COGO_PACKAGE)
+
+ // A failed adb call means we do not know, which is not the same as "not
+ // installed" - report it as an error rather than a negative answer.
+ if (result.exitCode != 0) {
+ return adbFailure(result)
+ }
+
+ // `pm list packages ` matches substrings, so com.itsaky.androidide.debug
+ // would satisfy a query for com.itsaky.androidide. Compare the parsed name
+ // exactly. adb shell may emit CRLF, so trim before comparing.
+ val installed =
+ result.stdout
+ .lineSequence()
+ .map { it.trim() }
+ .filter { it.startsWith("package:") }
+ .map { it.removePrefix("package:") }
+ .any { it == COGO_PACKAGE }
+
+ val message =
+ if (installed) {
+ "Code On The Go ($COGO_PACKAGE) is installed."
+ } else {
+ "Code On The Go ($COGO_PACKAGE) is NOT installed."
+ }
+
+ return CallToolResult(content = listOf(TextContent(message)))
+}
diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt
new file mode 100644
index 0000000000..c8bfa7062c
--- /dev/null
+++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt
@@ -0,0 +1,123 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.server.Server
+import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.Implementation
+import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
+import kotlinx.serialization.json.JsonObject
+
+const val SERVER_NAME = "cogo-mcp"
+const val SERVER_VERSION = "0.1.0"
+
+private val SERVER_INSTRUCTIONS =
+ """
+ Drives Code On The Go ($COGO_PACKAGE), an Android IDE that runs on the device
+ itself, by shelling out to adb on the host machine.
+
+ Tools act on whichever device adb selects by default. When several devices are
+ attached, adb's own error is returned rather than a guess at which one you meant.
+
+ A tool reporting an adb failure means the device could not be reached. That is
+ not the same as an answer: it does not mean the app is absent.
+ """.trimIndent()
+
+private val NO_ARGUMENTS = ToolSchema(properties = JsonObject(emptyMap()))
+
+fun cogoMcpServer(
+ adb: Adb = SystemAdb(),
+ // A cold start after force-stop measured ~6s on an emulator, so the budget
+ // needs headroom well past that.
+ homePollAttempts: Int = 30,
+ homePollDelayMillis: Long = 500,
+): Server {
+ val server =
+ Server(
+ serverInfo = Implementation(name = SERVER_NAME, version = SERVER_VERSION),
+ options =
+ ServerOptions(
+ // The tool set is fixed at construction, so there is nothing to notify about.
+ capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false)),
+ ),
+ instructions = SERVER_INSTRUCTIONS,
+ )
+
+ // Handler is suspend ClientConnection.(CallToolRequest) -> CallToolResult: the
+ // ClientConnection is the receiver, not a parameter. None of these use either.
+ server.addTool(
+ name = "ping",
+ title = "Ping",
+ description = "Health check. Returns pong. Does not touch the device.",
+ inputSchema = NO_ARGUMENTS,
+ ) { _ ->
+ CallToolResult(content = listOf(TextContent("pong")))
+ }
+
+ server.addTool(
+ name = "is_cogo_installed",
+ title = "Is Code On The Go installed?",
+ description =
+ "Report whether Code On The Go ($COGO_PACKAGE) is installed on the attached device. " +
+ "Reports an error, not a negative answer, when adb cannot reach a device.",
+ inputSchema = NO_ARGUMENTS,
+ ) { _ ->
+ isCogoInstalled(adb)
+ }
+
+ server.addTool(
+ name = "cogo_home",
+ title = "Go to Code On The Go home",
+ description =
+ "Bring Code On The Go to its home screen (the Get started screen) and confirm it arrived. " +
+ "Force-stops the app, so unsaved editor state is lost, and permanently disables the " +
+ "app's auto-open-project preference - without that the app reopens the last project " +
+ "and lands in the editor instead of home.",
+ inputSchema = NO_ARGUMENTS,
+ ) { _ ->
+ cogoHome(adb, homePollAttempts, homePollDelayMillis)
+ }
+
+ server.addTool(
+ name = "list_projects",
+ title = "List projects",
+ description =
+ "List the Code On The Go projects on the attached device. Scans $COGO_PROJECTS_DIR one level " +
+ "deep and returns only directories the IDE would actually open, so the result is usually " +
+ "shorter than a plain directory listing. Project names may contain spaces. An absent " +
+ "projects directory is a plain answer, not an error. Read-only.",
+ inputSchema = NO_ARGUMENTS,
+ ) { _ ->
+ listProjects(adb)
+ }
+
+ server.addTool(
+ name = "list_templates",
+ title = "List project templates",
+ description =
+ "List the project templates installed on the device - the same set the new-project wizard " +
+ "offers - with each template's name and description. An empty template directory is a " +
+ "plain answer, not an error: it normally means first-run onboarding has not finished. " +
+ "Read-only.",
+ inputSchema = NO_ARGUMENTS,
+ ) { _ ->
+ listTemplates(adb)
+ }
+
+ server.addTool(
+ name = "list_project_files",
+ title = "List files in the open project",
+ description =
+ "List the files in the project Code On The Go currently has open, as paths relative to the " +
+ "project root. Generated and VCS directories (build/, .gradle/, .git/) are omitted, and a " +
+ "listing over 500 files is truncated with an explicit TRUNCATED note - a truncated listing " +
+ "is not the whole project. Reports plainly, without an error, when no project is open. " +
+ "Read-only.",
+ inputSchema = NO_ARGUMENTS,
+ ) { _ ->
+ listProjectFiles(adb)
+ }
+
+ return server
+}
diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Main.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Main.kt
new file mode 100644
index 0000000000..0b47da9ebc
--- /dev/null
+++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Main.kt
@@ -0,0 +1,16 @@
+package com.itsaky.androidide.mcp
+
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.embeddedServer
+import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
+
+const val DEFAULT_PORT = 3000
+
+fun main(args: Array) {
+ val port = args.firstOrNull()?.toIntOrNull() ?: DEFAULT_PORT
+
+ // Loopback only: this server is unauthenticated.
+ embeddedServer(CIO, host = "127.0.0.1", port = port) {
+ mcpStreamableHttp { cogoMcpServer() }
+ }.start(wait = true)
+}
diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt
new file mode 100644
index 0000000000..a6ea4efc84
--- /dev/null
+++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt
@@ -0,0 +1,118 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+
+private const val LAST_PROJECT_KEY = "ide_last_project"
+
+// What the IDE stores when nothing is open. SharedPreferences escapes the angle
+// brackets, so it arrives as <NO_OPENED_PROJECT> and only matches after
+// unescaping.
+private const val NO_OPENED_PROJECT = ""
+
+private val EXCLUDED_DIRECTORIES = setOf("build", ".gradle", ".git")
+
+private const val EXCLUSION_NOTE = "build/, .gradle/ and .git/ are excluded."
+
+// A built project holds tens of thousands of generated files, which is more than
+// an agent can read or act on.
+internal const val MAX_LISTED_FILES = 500
+
+private const val NO_PROJECT_MESSAGE =
+ "No project is currently open in Code On The Go, so there are no project files to list. " +
+ "That is an answer, not a failure: open a project from the IDE's home screen and ask again."
+
+private val LAST_PROJECT_PATTERN =
+ Regex("""(.*?)""", RegexOption.DOT_MATCHES_ALL)
+
+/**
+ * Returns the project path the IDE currently has open, or null when it has none.
+ *
+ * Null covers every flavour of "nothing open" -- prefs file absent, key absent,
+ * empty value, or the sentinel -- because none of them is a failure.
+ */
+fun parseLastOpenedProject(xml: String): String? {
+ val recorded = LAST_PROJECT_PATTERN.find(xml)?.groupValues?.get(1) ?: return null
+ val path = unescapeXml(recorded).trim()
+ return path.takeIf { it.isNotEmpty() && it != NO_OPENED_PROJECT }
+}
+
+private fun unescapeXml(text: String) =
+ text
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", "\"")
+ .replace("'", "'")
+ // Last, so that an escaped entity such as < does not become a tag.
+ .replace("&", "&")
+
+// Projects live on shared storage, which run-as cannot read (the app sandbox's
+// storage view is not part of what run-as enters), so this one runs as the plain
+// shell user.
+internal fun listProjectFilesCommand(projectPath: String): String {
+ val excluded = EXCLUDED_DIRECTORIES.joinToString(" -o ") { "-name $it" }
+ return "find ${shellQuoted(projectPath)} \\( $excluded \\) -prune -o -type f -print"
+}
+
+// Project names contain spaces ("MyApp Basic K"), and the command reaches the
+// device as one string that its shell re-parses, so the path must arrive quoted.
+private fun shellQuoted(path: String) = "'" + path.replace("'", """'\''""") + "'"
+
+internal fun relativeProjectFiles(
+ projectPath: String,
+ findOutput: String,
+): List {
+ val prefix = projectPath.trimEnd('/') + "/"
+ return findOutput
+ .lineSequence()
+ // adb shell emits CRLF.
+ .map { it.trim() }
+ .filter { it.startsWith(prefix) }
+ .map { it.removePrefix(prefix) }
+ // find already prunes these; filtering again keeps the guarantee even if a
+ // device's find ignores -prune, and makes it testable without a device.
+ .filterNot { relative -> relative.split('/').any { it in EXCLUDED_DIRECTORIES } }
+ .distinct()
+ .sorted()
+ .toList()
+}
+
+internal fun describeProjectFiles(
+ projectPath: String,
+ files: List,
+): String {
+ val header = "Project: ${projectPath.trimEnd('/').substringAfterLast('/')}\nPath: $projectPath"
+ if (files.isEmpty()) {
+ return "$header\nNo files. $EXCLUSION_NOTE"
+ }
+
+ val shown = files.take(MAX_LISTED_FILES)
+ val summary =
+ if (files.size > shown.size) {
+ // Silent truncation reads as "that is everything", which it is not.
+ "TRUNCATED: showing the first ${shown.size} of ${files.size} files. $EXCLUSION_NOTE"
+ } else {
+ "${files.size} files, relative to the project root. $EXCLUSION_NOTE"
+ }
+
+ return "$header\n$summary\n\n${shown.joinToString("\n")}"
+}
+
+fun listProjectFiles(adb: Adb): CallToolResult {
+ val prefs = adb.shell(readCogoPreferencesCommand())
+ if (prefs.exitCode != 0) {
+ return adbFailure(prefs)
+ }
+
+ val projectPath =
+ parseLastOpenedProject(prefs.stdout)
+ ?: return CallToolResult(content = listOf(TextContent(NO_PROJECT_MESSAGE)))
+
+ val listing = adb.shell(listProjectFilesCommand(projectPath))
+ if (listing.exitCode != 0) {
+ return adbFailure(listing)
+ }
+
+ val files = relativeProjectFiles(projectPath, listing.stdout)
+ return CallToolResult(content = listOf(TextContent(describeProjectFiles(projectPath, files))))
+}
diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt
new file mode 100644
index 0000000000..eced5aec16
--- /dev/null
+++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt
@@ -0,0 +1,86 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+
+/**
+ * Environment.PROJECTS_DIR. World-readable external storage, so listing it needs no
+ * run-as.
+ */
+const val COGO_PROJECTS_DIR = "/storage/emulated/0/CodeOnTheGoProjects"
+
+private const val NO_DIR_MARKER = "NO_PROJECTS_DIR"
+
+// Names contain spaces, so a bare listing cannot be told apart from shell noise.
+// The prefix makes each line unambiguous and lets anything else be discarded.
+private const val PROJECT_MARKER = "PROJECT:"
+
+internal sealed interface ProjectListing {
+ /** The directory is absent, which is an answer rather than a failure. */
+ data object NoProjectsDir : ProjectListing
+
+ data class Found(
+ val names: List,
+ ) : ProjectListing
+}
+
+/**
+ * One shell command rather than a listing followed by a probe per candidate: it is a
+ * single round-trip, and it never moves a filename across the adb boundary, where a
+ * name containing spaces would have to be re-quoted to survive.
+ *
+ * Validity mirrors ProjectValidations.kt: a directory holding app/build.gradle{,.kts},
+ * or the plugin-project escape hatch of libs/plugin-api.jar plus a root build.gradle.kts.
+ * Those checks use `-e`, not `-f`, because the app tests only File.exists() too.
+ */
+internal fun listProjectsCommand(dir: String = COGO_PROJECTS_DIR): String =
+ buildString {
+ append("if [ ! -d \"$dir\" ]; then echo $NO_DIR_MARKER; exit 0; fi; ")
+ append("cd \"$dir\" || exit 0; ")
+ // `*/` expands to directories only and skips dot entries, so stray .cgt
+ // template files and .nomedia are excluded before any test runs. Glob results
+ // are not word-split, which is what keeps names like "MyApp Basic J" intact.
+ append("for p in */; do n=\${p%/}; ")
+ append("if [ -e \"\$n/app/build.gradle\" ] || [ -e \"\$n/app/build.gradle.kts\" ]")
+ append(" || { [ -e \"\$n/libs/plugin-api.jar\" ] && [ -e \"\$n/build.gradle.kts\" ]; }; ")
+ append("then echo \"$PROJECT_MARKER\$n\"; fi; ")
+ append("done")
+ }
+
+internal fun parseProjectListing(stdout: String): ProjectListing {
+ // trimEnd only: adb shell terminates lines with CRLF, and trimming the front
+ // would corrupt a name that legitimately starts with a space.
+ val lines = stdout.lineSequence().map { it.trimEnd() }.toList()
+ if (lines.any { it == NO_DIR_MARKER }) {
+ return ProjectListing.NoProjectsDir
+ }
+
+ return ProjectListing.Found(
+ lines.filter { it.startsWith(PROJECT_MARKER) }.map { it.removePrefix(PROJECT_MARKER) },
+ )
+}
+
+internal fun describeProjects(listing: ProjectListing): String =
+ when (listing) {
+ ProjectListing.NoProjectsDir -> {
+ "No projects directory on the device ($COGO_PROJECTS_DIR) - no projects have been created yet."
+ }
+
+ is ProjectListing.Found -> {
+ if (listing.names.isEmpty()) {
+ "No projects in $COGO_PROJECTS_DIR."
+ } else {
+ val noun = if (listing.names.size == 1) "project" else "projects"
+ "${listing.names.size} $noun in $COGO_PROJECTS_DIR:\n" + listing.names.joinToString("\n")
+ }
+ }
+ }
+
+fun listProjects(adb: Adb): CallToolResult {
+ val result = adb.shell(listProjectsCommand())
+ if (result.exitCode != 0) {
+ return adbFailure(result)
+ }
+
+ return CallToolResult(content = listOf(TextContent(describeProjects(parseProjectListing(result.stdout)))))
+}
diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt
new file mode 100644
index 0000000000..1d0b5b9484
--- /dev/null
+++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt
@@ -0,0 +1,124 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+
+private const val TEMPLATES_DIR = "files/home/.cg/templates"
+
+// core.cgt ships with the app; plugins add their own plugin__*.cgt beside it.
+private const val ARCHIVE_SUFFIX = ".cgt"
+
+private const val INDEX_MEMBER = "templates.json"
+
+// `ls` exits 1 on an absent directory, and the directory only appears once
+// onboarding has unpacked the templates - so guard it rather than read a
+// pre-onboarding device as an unreachable one.
+private val LIST_ARCHIVES_COMMAND = runAs("ls $TEMPLATES_DIR 2>/dev/null || true")
+
+private const val NOTHING_INSTALLED =
+ "No project templates are installed yet: $TEMPLATES_DIR is empty or missing. " +
+ "Code On The Go unpacks its templates during first-run onboarding, so this normally means " +
+ "onboarding has not finished rather than that anything is broken."
+
+// template.json is not strict JSON - it carries unquoted keys such as
+// `{identifier: "APP_NAME"}`, which JSONObject and kotlinx.serialization both
+// reject. The fields we want are quoted, so match them directly. The leading
+// quote keeps "name" from matching "appName".
+private fun stringFieldPattern(field: String) = Regex("\"$field\"\\s*:\\s*\"([^\"]*)\"")
+
+private val PATH_PATTERN = stringFieldPattern("path")
+
+private val NAME_PATTERN = stringFieldPattern("name")
+
+private val DESCRIPTION_PATTERN = stringFieldPattern("description")
+
+/** Template directory names listed by an archive's `templates.json`, in declared order. */
+fun parseTemplateIndex(json: String): List =
+ PATH_PATTERN
+ .findAll(json)
+ .map { it.groupValues[1].trim() }
+ .filter { it.isNotEmpty() }
+ .toList()
+
+/** Display name from a `template.json`, or null when it declares none. */
+fun parseTemplateName(json: String): String? = firstStringField(NAME_PATTERN, json)
+
+/** Description from a `template.json`, or null when it declares none. */
+fun parseTemplateDescription(json: String): String? = firstStringField(DESCRIPTION_PATTERN, json)
+
+// Both fields lead template.json, so the first match is the top-level one rather
+// than anything nested under "parameters".
+private fun firstStringField(
+ pattern: Regex,
+ json: String,
+): String? =
+ pattern
+ .find(json)
+ ?.groupValues
+ ?.get(1)
+ ?.trim()
+ ?.ifEmpty { null }
+
+private fun readMemberCommand(
+ archive: String,
+ member: String,
+) = runAs("unzip -p $TEMPLATES_DIR/$archive $member")
+
+fun listTemplates(adb: Adb): CallToolResult {
+ val listing = adb.shell(LIST_ARCHIVES_COMMAND)
+ if (listing.exitCode != 0) {
+ return adbFailure(listing)
+ }
+
+ // adb shell emits CRLF; an untrimmed name would build `unzip -p .../core.cgt\r`.
+ val archives =
+ listing.stdout
+ .lineSequence()
+ .map { it.trim() }
+ .filter { it.endsWith(ARCHIVE_SUFFIX) }
+ .toList()
+ if (archives.isEmpty()) {
+ return CallToolResult(content = listOf(TextContent(NOTHING_INSTALLED)))
+ }
+
+ val described = mutableListOf()
+ for (archive in archives) {
+ val index = adb.shell(readMemberCommand(archive, INDEX_MEMBER))
+ if (index.exitCode != 0) {
+ return adbFailure(index)
+ }
+ for (path in parseTemplateIndex(index.stdout)) {
+ val template = adb.shell(readMemberCommand(archive, "$path/template/template.json"))
+ if (template.exitCode != 0) {
+ return adbFailure(template)
+ }
+ described += describe(path, template.stdout)
+ }
+ }
+
+ if (described.isEmpty()) {
+ return CallToolResult(
+ content =
+ listOf(
+ TextContent(
+ "Found ${archives.joinToString(", ")} in $TEMPLATES_DIR, but no archive declares any templates.",
+ ),
+ ),
+ )
+ }
+
+ val plural = if (described.size == 1) "template" else "templates"
+ val heading = "Code On The Go has ${described.size} project $plural installed:"
+ return CallToolResult(content = listOf(TextContent((listOf(heading) + described).joinToString("\n"))))
+}
+
+// The directory name is the fallback label: a template with no declared name is
+// still selectable, so naming nothing would be worse than naming it awkwardly.
+private fun describe(
+ path: String,
+ templateJson: String,
+): String {
+ val name = parseTemplateName(templateJson) ?: path
+ val description = parseTemplateDescription(templateJson)
+ return if (description == null) name else "$name - $description"
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/AutoOpenPreferenceTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/AutoOpenPreferenceTest.kt
new file mode 100644
index 0000000000..c62eabf29d
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/AutoOpenPreferenceTest.kt
@@ -0,0 +1,62 @@
+package com.itsaky.androidide.mcp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private const val KEY = "idepref_general_autoOpenProjects"
+
+private fun occurrencesOfKey(xml: String) = Regex(KEY).findAll(xml).count()
+
+class AutoOpenPreferenceTest {
+ @Test
+ fun `creates a preferences document when none exists`() {
+ val xml = withAutoOpenDisabled("")
+
+ assertTrue(xml.contains(""), xml)
+ assertTrue(xml.contains(""), xml)
+ assertTrue(xml.contains(""""""), xml)
+ }
+
+ // Regression: the first version of this edit was line-based sed on-device.
+ // A prior run left and the boolean sharing one line, so deleting the
+ // line took with it and left the file corrupt on the second run.
+ @Test
+ fun `is idempotent and keeps the map element intact`() {
+ val once = withAutoOpenDisabled("")
+ val twice = withAutoOpenDisabled(once)
+
+ assertEquals(once, twice)
+ assertEquals(1, occurrencesOfKey(twice), twice)
+ assertTrue(twice.contains(""), twice)
+ }
+
+ @Test
+ fun `preserves unrelated preferences and overwrites an existing true value`() {
+ val existing =
+ """
+
+
+ dark
+
+
+ """.trimIndent()
+
+ val xml = withAutoOpenDisabled(existing)
+
+ assertTrue(xml.contains("""dark"""), xml)
+ assertTrue(xml.contains(""""""), xml)
+ assertFalse(xml.contains("""value="true""""), xml)
+ assertEquals(1, occurrencesOfKey(xml), xml)
+ }
+
+ @Test
+ fun `handles a self-closing map element`() {
+ val xml = withAutoOpenDisabled("\n")
+
+ assertTrue(xml.contains(""""""), xml)
+ assertFalse(xml.contains(""), xml)
+ assertEquals(1, occurrencesOfKey(xml), xml)
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoHomeTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoHomeTest.kt
new file mode 100644
index 0000000000..6c2688f019
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoHomeTest.kt
@@ -0,0 +1,131 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import java.util.Base64
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+private const val HOME_DUMPSYS =
+ " topResumedActivity=ActivityRecord{127760145 u0 com.itsaky.androidide/.activities.MainActivity t84}"
+
+private const val EDITOR_DUMPSYS =
+ " topResumedActivity=ActivityRecord{127760145 u0 com.itsaky.androidide/.activities.editor.EditorActivityKt t84}"
+
+private class RecordingAdb(
+ private val responder: (List) -> AdbResult,
+) : Adb {
+ val calls = mutableListOf>()
+
+ override fun run(args: List): AdbResult {
+ calls += args
+ return responder(args)
+ }
+}
+
+private fun adbLandingOn(dumpsys: String) =
+ RecordingAdb { args ->
+ if (args.any { it.contains("dumpsys") }) {
+ AdbResult(exitCode = 0, stdout = dumpsys, stderr = "")
+ } else {
+ AdbResult(exitCode = 0, stdout = "", stderr = "")
+ }
+ }
+
+class CogoHomeTest {
+ private fun goHome(adb: Adb): CallToolResult =
+ withConnectedClient({ cogoMcpServer(adb = adb, homePollDelayMillis = 0) }) { client ->
+ client.callTool(name = "cogo_home", arguments = emptyMap())
+ }
+
+ private fun textOf(result: CallToolResult): String =
+ result.content
+ .filterIsInstance()
+ .single()
+ .text
+
+ @Test
+ fun `reports success when the app lands on MainActivity`() {
+ val result = goHome(adbLandingOn(HOME_DUMPSYS))
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals("Code On The Go is on its home screen (MainActivity).", textOf(result))
+ }
+
+ // Auto-open-project sends the app straight to the editor. If that still
+ // happens, saying "done" would be a lie the agent cannot detect.
+ @Test
+ fun `reports an error when the app lands somewhere other than home`() {
+ val result = goHome(adbLandingOn(EDITOR_DUMPSYS))
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("EditorActivityKt"), textOf(result))
+ assertTrue(textOf(result).contains("not the home screen"), textOf(result))
+ }
+
+ @Test
+ fun `reports an error when adb fails`() {
+ val failing = Adb { AdbResult(exitCode = 1, stdout = "", stderr = "adb: no devices/emulators found") }
+
+ val result = goHome(failing)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("no devices/emulators found"), textOf(result))
+ }
+
+ @Test
+ fun `force-stops, reads prefs, writes prefs, then launches MainActivity`() {
+ val adb = adbLandingOn(HOME_DUMPSYS)
+
+ goHome(adb)
+
+ assertEquals(
+ listOf("shell", "am", "force-stop", "com.itsaky.androidide"),
+ adb.calls[0],
+ "the app must be stopped before its preferences are rewritten, or it would overwrite them on exit",
+ )
+ assertTrue(adb.calls[1].joinToString(" ").contains("cat shared_prefs"), "${adb.calls[1]}")
+ assertTrue(adb.calls[2].joinToString(" ").contains("base64 -d"), "${adb.calls[2]}")
+ assertEquals(
+ listOf("shell", "am", "start", "-n", "com.itsaky.androidide/.activities.MainActivity"),
+ adb.calls[3],
+ )
+ assertTrue(adb.calls[4].any { it.contains("dumpsys") }, "${adb.calls[4]}")
+ }
+
+ // tryOpenLastProject() falls back to the most recently modified project when
+ // no last project is recorded, so clearing ide_last_project is not enough --
+ // only the boolean actually prevents the jump to the editor.
+ @Test
+ fun `writes back preferences with autoOpenProjects disabled`() {
+ val adb = adbLandingOn(HOME_DUMPSYS)
+
+ goHome(adb)
+
+ val written = decodeWrittenXml(adb.calls[2].joinToString(" "))
+ assertTrue(
+ written.contains(""""""),
+ written,
+ )
+ assertTrue(adb.calls[2].joinToString(" ").contains("com.itsaky.androidide_preferences.xml"))
+ }
+
+ // A missing prefs file must not look like an adb failure: the read is
+ // deliberately written so it exits 0 with empty output when absent.
+ @Test
+ fun `treats an absent preferences file as empty rather than an error`() {
+ val adb = adbLandingOn(HOME_DUMPSYS)
+
+ goHome(adb)
+
+ assertTrue(adb.calls[1].joinToString(" ").contains("2>/dev/null"), "${adb.calls[1]}")
+ val written = decodeWrittenXml(adb.calls[2].joinToString(" "))
+ assertTrue(written.contains(""), written)
+ }
+
+ private fun decodeWrittenXml(command: String): String {
+ val encoded = command.substringAfter("echo ").substringBefore(" |").trim()
+ return String(Base64.getDecoder().decode(encoded))
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoInstalledTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoInstalledTest.kt
new file mode 100644
index 0000000000..4c8ef5049b
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoInstalledTest.kt
@@ -0,0 +1,68 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class CogoInstalledTest {
+ private fun succeedingAdb(stdout: String) = Adb { AdbResult(exitCode = 0, stdout = stdout, stderr = "") }
+
+ private fun probe(adb: Adb): CallToolResult =
+ withConnectedClient({ cogoMcpServer(adb) }) { client ->
+ client.callTool(name = "is_cogo_installed", arguments = emptyMap())
+ }
+
+ private fun textOf(result: CallToolResult): String =
+ result.content
+ .filterIsInstance()
+ .single()
+ .text
+
+ @Test
+ fun `reports installed when the package is present`() {
+ val result = probe(succeedingAdb("package:com.itsaky.androidide\n"))
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals("Code On The Go (com.itsaky.androidide) is installed.", textOf(result))
+ }
+
+ @Test
+ fun `reports not installed when the package is absent`() {
+ val result = probe(succeedingAdb("package:com.android.settings\n"))
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals("Code On The Go (com.itsaky.androidide) is NOT installed.", textOf(result))
+ }
+
+ // `pm list packages ` matches substrings, so a same-prefix package
+ // comes back from the very query used to look for the real one.
+ @Test
+ fun `does not mistake a same-prefix package for the real one`() {
+ val result = probe(succeedingAdb("package:com.itsaky.androidide.debug\r\n"))
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals("Code On The Go (com.itsaky.androidide) is NOT installed.", textOf(result))
+ }
+
+ // adb shell emits CRLF; an untrimmed compare against "com.itsaky.androidide\r"
+ // would silently never match.
+ @Test
+ fun `tolerates the CRLF line endings adb shell emits`() {
+ val result = probe(succeedingAdb("package:com.itsaky.androidide\r\n"))
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals("Code On The Go (com.itsaky.androidide) is installed.", textOf(result))
+ }
+
+ @Test
+ fun `reports an error when adb fails instead of reporting not installed`() {
+ val failing = Adb { AdbResult(exitCode = 1, stdout = "", stderr = "adb: device 'x' not found\n") }
+
+ val result = probe(failing)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("device 'x' not found"), textOf(result))
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/McpTestFixture.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/McpTestFixture.kt
new file mode 100644
index 0000000000..ea55cde5e6
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/McpTestFixture.kt
@@ -0,0 +1,42 @@
+package com.itsaky.androidide.mcp
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.sse.SSE
+import io.ktor.server.engine.embeddedServer
+import io.modelcontextprotocol.kotlin.sdk.client.Client
+import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttpTransport
+import io.modelcontextprotocol.kotlin.sdk.server.Server
+import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
+import io.modelcontextprotocol.kotlin.sdk.types.Implementation
+import kotlinx.coroutines.runBlocking
+import io.ktor.client.engine.cio.CIO as ClientCIO
+import io.ktor.server.cio.CIO as ServerCIO
+
+// Port 0 so the suite never collides with a real server on 3000.
+fun withConnectedClient(
+ serverFactory: () -> Server,
+ block: suspend (Client) -> T,
+): T =
+ runBlocking {
+ val engine =
+ embeddedServer(ServerCIO, host = "127.0.0.1", port = 0) {
+ mcpStreamableHttp { serverFactory() }
+ }.start(wait = false)
+ try {
+ val port =
+ engine.engine
+ .resolvedConnectors()
+ .first()
+ .port
+ val http = HttpClient(ClientCIO) { install(SSE) }
+ try {
+ val client = Client(Implementation(name = "cogo-mcp-test", version = "0.1.0"))
+ client.connect(http.mcpStreamableHttpTransport("http://127.0.0.1:$port/mcp"))
+ block(client)
+ } finally {
+ http.close()
+ }
+ } finally {
+ engine.stop(gracePeriodMillis = 0, timeoutMillis = 2000)
+ }
+ }
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt
new file mode 100644
index 0000000000..b91ee0eac4
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt
@@ -0,0 +1,45 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PingTest {
+ @Test
+ fun `handshake reports the server identity`() =
+ withConnectedClient({ cogoMcpServer() }) { client ->
+ assertEquals("cogo-mcp", client.serverVersion?.name)
+ }
+
+ @Test
+ fun `tools list contains exactly the registered tools`() =
+ withConnectedClient({ cogoMcpServer() }) { client ->
+ assertEquals(
+ setOf(
+ "ping",
+ "is_cogo_installed",
+ "cogo_home",
+ "list_projects",
+ "list_templates",
+ "list_project_files",
+ ),
+ client
+ .listTools()
+ .tools
+ .map { it.name }
+ .toSet(),
+ )
+ }
+
+ @Test
+ fun `calling ping returns pong`() =
+ withConnectedClient({ cogoMcpServer() }) { client ->
+ val result = client.callTool(name = "ping", arguments = emptyMap())
+ val text =
+ result.content
+ .filterIsInstance()
+ .single()
+ .text
+ assertEquals("pong", text)
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectFilesTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectFilesTest.kt
new file mode 100644
index 0000000000..f932ba1dad
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectFilesTest.kt
@@ -0,0 +1,215 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+private const val PROJECT_PATH = "/storage/emulated/0/CodeOnTheGoProjects/MyApp Basic K"
+
+private class ScriptedAdb(
+ private val responder: (List) -> AdbResult,
+) : Adb {
+ val calls = mutableListOf>()
+
+ override fun run(args: List): AdbResult {
+ calls += args
+ return responder(args)
+ }
+}
+
+private fun prefsRecording(value: String) =
+ """
+
+
+
+ $value
+
+ """.trimIndent()
+
+// adb shell emits CRLF, so every fake listing does too.
+private fun findOutput(vararg relativePaths: String) = relativePaths.joinToString("\r\n") { "$PROJECT_PATH/$it" } + "\r\n"
+
+private fun adbServing(
+ prefs: String,
+ listing: String,
+) = ScriptedAdb { args ->
+ if (args.joinToString(" ").contains("cat shared_prefs")) {
+ AdbResult(exitCode = 0, stdout = prefs, stderr = "")
+ } else {
+ AdbResult(exitCode = 0, stdout = listing, stderr = "")
+ }
+}
+
+class ProjectFilesTest {
+ private fun textOf(result: CallToolResult): String =
+ result.content
+ .filterIsInstance()
+ .single()
+ .text
+
+ @Test
+ fun `finds the project path recorded in the preferences document`() {
+ val path = parseLastOpenedProject(prefsRecording("/storage/emulated/0/CodeOnTheGoProjects/GetxDemo"))
+
+ assertEquals("/storage/emulated/0/CodeOnTheGoProjects/GetxDemo", path)
+ }
+
+ // Real project names contain spaces ("MyApp Basic K"), so anything that splits
+ // the value on whitespace silently reads the wrong directory.
+ @Test
+ fun `keeps a project path that contains spaces intact`() {
+ val path = parseLastOpenedProject(prefsRecording(PROJECT_PATH))
+
+ assertEquals(PROJECT_PATH, path)
+ }
+
+ @Test
+ fun `decodes xml escapes in the project path`() {
+ val path = parseLastOpenedProject(prefsRecording("/storage/emulated/0/CodeOnTheGoProjects/Ben & Jerry"))
+
+ assertEquals("/storage/emulated/0/CodeOnTheGoProjects/Ben & Jerry", path)
+ }
+
+ @Test
+ fun `reads the sentinel value as nothing open`() {
+ assertNull(parseLastOpenedProject(prefsRecording("")))
+ }
+
+ // SharedPreferences escapes the angle brackets on the way out, so the sentinel
+ // arrives as <NO_OPENED_PROJECT> and would otherwise read as a real path.
+ @Test
+ fun `reads the escaped sentinel value as nothing open`() {
+ assertNull(parseLastOpenedProject(prefsRecording("<NO_OPENED_PROJECT>")))
+ }
+
+ @Test
+ fun `reads an absent preferences file as nothing open`() {
+ assertNull(parseLastOpenedProject(""))
+ }
+
+ @Test
+ fun `reads a preferences document without the key as nothing open`() {
+ val xml =
+ """
+
+
+
+
+ """.trimIndent()
+
+ assertNull(parseLastOpenedProject(xml))
+ }
+
+ // "Nothing is open" is an answer, not a failure. An agent that cannot tell the
+ // two apart would report a broken device when the IDE is merely at home.
+ @Test
+ fun `says no project is open without reporting an error`() {
+ val adb = adbServing(prefsRecording(""), "")
+
+ val result = listProjectFiles(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(textOf(result).contains("No project is currently open"), textOf(result))
+ assertEquals(1, adb.calls.size, "nothing to list, so no second adb call: ${adb.calls}")
+ }
+
+ @Test
+ fun `says no project is open when the preferences file is missing`() {
+ val result = listProjectFiles(adbServing("", ""))
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(textOf(result).contains("No project is currently open"), textOf(result))
+ }
+
+ @Test
+ fun `lists files relative to the project root`() {
+ val listing = findOutput("settings.gradle.kts", "app/src/main/AndroidManifest.xml")
+
+ val result = listProjectFiles(adbServing(prefsRecording(PROJECT_PATH), listing))
+ val text = textOf(result)
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(text.contains("MyApp Basic K"), text)
+ assertTrue(text.lines().contains("settings.gradle.kts"), text)
+ assertTrue(text.lines().contains("app/src/main/AndroidManifest.xml"), text)
+ assertFalse(text.contains("$PROJECT_PATH/app"), "paths must be relative, not absolute: $text")
+ }
+
+ // The whole command travels as one string through adb and the device shell, so
+ // an unquoted path turns "MyApp Basic K" into three arguments.
+ @Test
+ fun `quotes a project path containing spaces in the listing command`() {
+ val adb = adbServing(prefsRecording(PROJECT_PATH), findOutput("settings.gradle.kts"))
+
+ listProjectFiles(adb)
+
+ val command = adb.calls[1].joinToString(" ")
+ assertTrue(command.contains("'$PROJECT_PATH'"), command)
+ }
+
+ @Test
+ fun `leaves out build, gradle and git entries`() {
+ val listing =
+ findOutput(
+ "build/generated/Noise.kt",
+ "app/build/intermediates/noise.jar",
+ ".gradle/file-system.probe",
+ ".git/HEAD",
+ "app/build.gradle.kts",
+ "buildSrc/Deps.kt",
+ )
+
+ val text = textOf(listProjectFiles(adbServing(prefsRecording(PROJECT_PATH), listing)))
+
+ assertFalse(text.contains("Noise.kt"), text)
+ assertFalse(text.contains("intermediates"), text)
+ assertFalse(text.contains("file-system.probe"), text)
+ assertFalse(text.contains("HEAD"), text)
+ assertTrue(text.lines().contains("app/build.gradle.kts"), "a file merely named build.gradle.kts must survive: $text")
+ assertTrue(text.lines().contains("buildSrc/Deps.kt"), "buildSrc is not a build directory: $text")
+ }
+
+ // Silent truncation reads as "that is the whole project", which is a lie the
+ // agent cannot detect.
+ @Test
+ fun `announces that a long listing is truncated`() {
+ val paths = (1..MAX_LISTED_FILES + 25).map { "src/File$it.kt" }.toTypedArray()
+
+ val text = textOf(listProjectFiles(adbServing(prefsRecording(PROJECT_PATH), findOutput(*paths))))
+
+ assertTrue(text.contains("TRUNCATED"), text)
+ assertTrue(text.contains("${MAX_LISTED_FILES + 25}"), text)
+ assertEquals(MAX_LISTED_FILES, text.lines().count { it.startsWith("src/File") }, text)
+ }
+
+ @Test
+ fun `reports an error when the preferences read fails`() {
+ val failing = Adb { AdbResult(exitCode = 1, stdout = "", stderr = "adb: no devices/emulators found") }
+
+ val result = listProjectFiles(failing)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("no devices/emulators found"), textOf(result))
+ }
+
+ @Test
+ fun `reports an error when the file listing fails`() {
+ val adb =
+ ScriptedAdb { args ->
+ if (args.joinToString(" ").contains("cat shared_prefs")) {
+ AdbResult(exitCode = 0, stdout = prefsRecording(PROJECT_PATH), stderr = "")
+ } else {
+ AdbResult(exitCode = 1, stdout = "", stderr = "find: '$PROJECT_PATH': No such file or directory")
+ }
+ }
+
+ val result = listProjectFiles(adb)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("No such file or directory"), textOf(result))
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectsTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectsTest.kt
new file mode 100644
index 0000000000..15adfd4dcf
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectsTest.kt
@@ -0,0 +1,229 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ProjectsTest {
+ private fun withProjectsRoot(block: (File) -> T): T {
+ val root = Files.createTempDirectory("cogo-projects").toFile()
+ return try {
+ block(root)
+ } finally {
+ root.deleteRecursively()
+ }
+ }
+
+ // The filter lives in the device shell, so the only honest way to test it is to
+ // run the very command we ship against a real directory tree. The command is
+ // POSIX sh, which both the host and Android's shell speak.
+ private fun listingOf(dir: String): ProjectListing {
+ val result = SystemAdb(executable = "/bin/sh").run(listOf("-c", listProjectsCommand(dir)))
+
+ assertEquals(0, result.exitCode, result.stderr)
+ return parseProjectListing(result.stdout)
+ }
+
+ private fun namesIn(dir: String): List = (listingOf(dir) as ProjectListing.Found).names
+
+ private fun androidProject(
+ root: File,
+ name: String,
+ buildFile: String = "build.gradle.kts",
+ ) {
+ File(root, "$name/app").mkdirs()
+ File(root, "$name/app/$buildFile").writeText("")
+ }
+
+ private fun pluginProject(
+ root: File,
+ name: String,
+ ) {
+ File(root, "$name/libs").mkdirs()
+ File(root, "$name/libs/plugin-api.jar").writeText("")
+ File(root, "$name/build.gradle.kts").writeText("")
+ }
+
+ private fun textOf(result: CallToolResult): String =
+ result.content
+ .filterIsInstance()
+ .single()
+ .text
+
+ // Mirrors the real device: the projects directory holds stray .cgt template
+ // files and a .nomedia alongside the actual project directories.
+ @Test
+ fun `lists project directories and ignores stray files`() {
+ withProjectsRoot { root ->
+ androidProject(root, "MyApp Basic K")
+ File(root, "Alpha_Template.cgt").writeText("")
+ File(root, ".nomedia").writeText("")
+
+ assertEquals(listOf("MyApp Basic K"), namesIn(root.absolutePath))
+ }
+ }
+
+ // The regression that matters most: real project names contain spaces, and a
+ // command that word-splits paths silently reports nothing.
+ @Test
+ fun `handles project names containing spaces`() {
+ withProjectsRoot { root ->
+ androidProject(root, "My Application-cgt")
+ androidProject(root, "MyApp Basic J")
+
+ assertEquals(listOf("My Application-cgt", "MyApp Basic J"), namesIn(root.absolutePath))
+ }
+ }
+
+ // GetxDemo and ProviderDemo on the device are Flutter projects: directories with
+ // no app/ at all, which Code On The Go does not offer to open.
+ @Test
+ fun `excludes a directory without an app build script`() {
+ withProjectsRoot { root ->
+ File(root, "GetxDemo/lib").mkdirs()
+ File(root, "GetxDemo/pubspec.yaml").writeText("")
+ androidProject(root, "RealApp")
+
+ assertEquals(listOf("RealApp"), namesIn(root.absolutePath))
+ }
+ }
+
+ @Test
+ fun `accepts a groovy app build script`() {
+ withProjectsRoot { root ->
+ androidProject(root, "GroovyApp", buildFile = "build.gradle")
+
+ assertEquals(listOf("GroovyApp"), namesIn(root.absolutePath))
+ }
+ }
+
+ @Test
+ fun `accepts a plugin project`() {
+ withProjectsRoot { root ->
+ pluginProject(root, "MyPlugin")
+
+ assertEquals(listOf("MyPlugin"), namesIn(root.absolutePath))
+ }
+ }
+
+ // A plugin-api jar alone is not the escape hatch; the root build script is
+ // required too.
+ @Test
+ fun `rejects a plugin project missing its root build script`() {
+ withProjectsRoot { root ->
+ File(root, "HalfPlugin/libs").mkdirs()
+ File(root, "HalfPlugin/libs/plugin-api.jar").writeText("")
+
+ assertEquals(emptyList(), namesIn(root.absolutePath))
+ }
+ }
+
+ @Test
+ fun `skips hidden directories`() {
+ withProjectsRoot { root ->
+ androidProject(root, ".hidden")
+
+ assertEquals(emptyList(), namesIn(root.absolutePath))
+ }
+ }
+
+ @Test
+ fun `reports an empty projects directory as found but empty`() {
+ withProjectsRoot { root ->
+ assertEquals(emptyList(), namesIn(root.absolutePath))
+ }
+ }
+
+ @Test
+ fun `reports a missing projects directory distinctly`() {
+ withProjectsRoot { root ->
+ assertEquals(ProjectListing.NoProjectsDir, listingOf(File(root, "absent").absolutePath))
+ }
+ }
+
+ @Test
+ fun `tolerates the CRLF line endings adb shell emits`() {
+ val listing = parseProjectListing("PROJECT:MyApp Basic J\r\nPROJECT:MyApp Basic K\r\n")
+
+ assertEquals(listOf("MyApp Basic J", "MyApp Basic K"), (listing as ProjectListing.Found).names)
+ }
+
+ @Test
+ fun `ignores unrelated shell output`() {
+ val listing = parseProjectListing("WARNING: linker something\nPROJECT:Solo\n")
+
+ assertEquals(listOf("Solo"), (listing as ProjectListing.Found).names)
+ }
+
+ @Test
+ fun `describes projects with a count and one name per line`() {
+ val adb = Adb { AdbResult(0, "PROJECT:MyApp Basic J\nPROJECT:MyApp Basic K\n", "") }
+
+ val result = listProjects(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals(
+ "2 projects in $COGO_PROJECTS_DIR:\nMyApp Basic J\nMyApp Basic K",
+ textOf(result),
+ )
+ }
+
+ @Test
+ fun `uses the singular for a lone project`() {
+ val adb = Adb { AdbResult(0, "PROJECT:Solo\n", "") }
+
+ assertEquals("1 project in $COGO_PROJECTS_DIR:\nSolo", textOf(listProjects(adb)))
+ }
+
+ @Test
+ fun `reports an empty projects directory without an error`() {
+ val adb = Adb { AdbResult(0, "", "") }
+
+ val result = listProjects(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals("No projects in $COGO_PROJECTS_DIR.", textOf(result))
+ }
+
+ // A missing directory is an answer, not a failure: the app simply has not
+ // created it yet.
+ @Test
+ fun `reports a missing projects directory without an error`() {
+ val adb = Adb { AdbResult(0, "NO_PROJECTS_DIR\n", "") }
+
+ val result = listProjects(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(textOf(result).contains("No projects directory"), textOf(result))
+ assertFalse(textOf(result).contains("adb failed"), textOf(result))
+ }
+
+ @Test
+ fun `reports an error when adb fails instead of an empty list`() {
+ val adb = Adb { AdbResult(exitCode = 1, stdout = "", stderr = "adb: device 'x' not found\n") }
+
+ val result = listProjects(adb)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("device 'x' not found"), textOf(result))
+ }
+
+ // The whole listing is one `adb shell `, so a slow device is paid for
+ // once rather than once per candidate directory.
+ @Test
+ fun `asks adb for a device shell exactly once`() {
+ val calls = mutableListOf>()
+ listProjects { args ->
+ calls += args
+ AdbResult(0, "", "")
+ }
+
+ assertEquals(1, calls.size)
+ assertEquals(listOf("shell", listProjectsCommand()), calls.single())
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt
new file mode 100644
index 0000000000..b554ffa9c7
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt
@@ -0,0 +1,14 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.Implementation
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SdkResolutionTest {
+ @Test
+ fun `sdk types load under this kotlin version`() {
+ val info = Implementation(name = "cogo-mcp", version = "0.1.0")
+ assertEquals("cogo-mcp", info.name)
+ assertEquals("0.1.0", info.version)
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt
new file mode 100644
index 0000000000..0cd2a8ab9e
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt
@@ -0,0 +1,43 @@
+package com.itsaky.androidide.mcp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+// The tool descriptions are the only signal an agent gets about when to reach
+// for a tool, and the initialize instructions are the only signal about what
+// the server is for at all. Both are asserted here rather than left to review.
+class ServerDescriptionTest {
+ @Test
+ fun `initialize carries instructions describing what the server drives`() =
+ withConnectedClient({ cogoMcpServer() }) { client ->
+ val instructions = client.serverInstructions
+
+ assertNotNull(instructions, "server sent no instructions on initialize")
+ assertTrue(instructions.contains("Code On The Go"), instructions)
+ assertTrue(instructions.contains("adb"), instructions)
+ }
+
+ @Test
+ fun `does not advertise listChanged, since the tool set is static`() =
+ withConnectedClient({ cogoMcpServer() }) { client ->
+ assertEquals(false, client.serverCapabilities?.tools?.listChanged)
+ }
+
+ @Test
+ fun `every tool advertises a human readable title`() =
+ withConnectedClient({ cogoMcpServer() }) { client ->
+ assertEquals(
+ mapOf(
+ "ping" to "Ping",
+ "is_cogo_installed" to "Is Code On The Go installed?",
+ "cogo_home" to "Go to Code On The Go home",
+ "list_projects" to "List projects",
+ "list_templates" to "List project templates",
+ "list_project_files" to "List files in the open project",
+ ),
+ client.listTools().tools.associate { it.name to it.title },
+ )
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/SystemAdbTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/SystemAdbTest.kt
new file mode 100644
index 0000000000..4258d94207
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/SystemAdbTest.kt
@@ -0,0 +1,24 @@
+package com.itsaky.androidide.mcp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+// Exercises the real process boundary against commands that exist everywhere,
+// so the adapter is covered without depending on adb or an attached device.
+class SystemAdbTest {
+ @Test
+ fun `captures stdout and a zero exit code`() {
+ val result = SystemAdb(executable = "/bin/echo").run(listOf("hello"))
+
+ assertEquals(0, result.exitCode)
+ assertEquals("hello", result.stdout.trim())
+ }
+
+ @Test
+ fun `propagates a non-zero exit code and stderr`() {
+ val result = SystemAdb(executable = "/bin/sh").run(listOf("-c", "echo boom >&2; exit 3"))
+
+ assertEquals(3, result.exitCode)
+ assertEquals("boom", result.stderr.trim())
+ }
+}
diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/TemplatesTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/TemplatesTest.kt
new file mode 100644
index 0000000000..178d1d922a
--- /dev/null
+++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/TemplatesTest.kt
@@ -0,0 +1,303 @@
+package com.itsaky.androidide.mcp
+
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+// Verbatim from `unzip -p files/home/.cg/templates/core.cgt templates.json` on a
+// device: the spacing really is inconsistent between entries.
+private val CORE_INDEX =
+ """
+ {
+ "templates": [
+ { "path": "NoActivity" },
+ { "path": "EmptyActivity" },
+ { "path": "BasicActivity" },
+ { "path": "NavigationDrawer" },
+ { "path": "BottomNavActivity"},
+ { "path": "NoAndroidX"},
+ { "path": "TabbedActivity"},
+ { "path": "ComposeActivity"},
+ { "path": "CodeOnTheGoPlugin"}
+ ]
+ }
+ """.trimIndent()
+
+// Verbatim from EmptyActivity/template/template.json. The `{identifier: "APP_NAME"}`
+// keys are unquoted, so this is not strict JSON and a real parser rejects it.
+private val EMPTY_ACTIVITY_TEMPLATE =
+ """
+ {
+ "name": "Empty Activity",
+ "description": "Creates a new empty activity",
+ "version": "0.1",
+ "tooltipTag": "template.empty.activity",
+ "parameters": {
+ "required": {
+ "appName": {identifier: "APP_NAME"},
+ "packageName": {identifier: "PACKAGE_NAME"},
+ "saveLocation": {identifier: "SAVE_LOCATION"}
+ },
+ "optional": {
+ "language": {identifier: "LANGUAGE"},
+ "minsdk": {identifier: "MIN_SDK"}
+ }
+ },
+ "system": {
+ "agpVersion": { "identifier": "AGP_VERSION" }
+ }
+ }
+ """.trimIndent()
+
+private val NO_ACTIVITY_TEMPLATE =
+ """
+ {
+ "name": "No Activity",
+ "description": "Creates a no activity",
+ "version": "0.1",
+ "parameters": {
+ "required": {
+ "appName": {identifier: "APP_NAME"}
+ }
+ }
+ }
+ """.trimIndent()
+
+private class FakeAdb(
+ private val responder: (String) -> AdbResult,
+) : Adb {
+ val commands = mutableListOf()
+
+ override fun run(args: List): AdbResult {
+ val command = args.joinToString(" ")
+ commands += command
+ return responder(command)
+ }
+}
+
+private fun ok(stdout: String) = AdbResult(exitCode = 0, stdout = stdout, stderr = "")
+
+// Answers `ls` with [listing] and every `unzip -p` from [members], keyed
+// "!". An unknown member fails the way unzip really does.
+private fun fakeDevice(
+ listing: String,
+ members: Map,
+) = FakeAdb { command ->
+ if (!command.contains("unzip")) {
+ ok(listing)
+ } else {
+ members
+ .entries
+ .firstOrNull { (key, _) ->
+ val (archive, member) = key.split("!")
+ command.contains(archive) && command.contains(member)
+ }?.let { ok(it.value) }
+ ?: AdbResult(exitCode = 11, stdout = "", stderr = "caution: filename not matched")
+ }
+}
+
+private fun textOf(result: CallToolResult): String =
+ result.content
+ .filterIsInstance()
+ .single()
+ .text
+
+class TemplatesTest {
+ @Test
+ fun `parses every template path from an archive index`() {
+ assertEquals(
+ listOf(
+ "NoActivity",
+ "EmptyActivity",
+ "BasicActivity",
+ "NavigationDrawer",
+ "BottomNavActivity",
+ "NoAndroidX",
+ "TabbedActivity",
+ "ComposeActivity",
+ "CodeOnTheGoPlugin",
+ ),
+ parseTemplateIndex(CORE_INDEX),
+ )
+ }
+
+ @Test
+ fun `parses an index with no templates as empty rather than failing`() {
+ assertEquals(emptyList(), parseTemplateIndex("""{ "templates": [] }"""))
+ }
+
+ // The regression that matters: template.json is not strict JSON, so a real JSON
+ // parser throws on the unquoted `{identifier: "APP_NAME"}` key.
+ @Test
+ fun `extracts name and description from a template json with unquoted identifier keys`() {
+ assertTrue(
+ EMPTY_ACTIVITY_TEMPLATE.contains("""{identifier: "APP_NAME"}"""),
+ "the fixture must exercise the unquoted key",
+ )
+
+ assertEquals("Empty Activity", parseTemplateName(EMPTY_ACTIVITY_TEMPLATE))
+ assertEquals("Creates a new empty activity", parseTemplateDescription(EMPTY_ACTIVITY_TEMPLATE))
+ }
+
+ @Test
+ fun `reports a missing name or description as null`() {
+ assertNull(parseTemplateName("""{ "version": "0.1" }"""))
+ assertNull(parseTemplateDescription("""{ "name": "Empty Activity" }"""))
+ }
+
+ @Test
+ fun `lists one template per line as name then description`() {
+ val adb =
+ fakeDevice(
+ listing = "core.cgt\n",
+ members =
+ mapOf(
+ "core.cgt!templates.json" to """{ "templates": [ { "path": "NoActivity" }, { "path": "EmptyActivity" } ] }""",
+ "core.cgt!NoActivity/template/template.json" to NO_ACTIVITY_TEMPLATE,
+ "core.cgt!EmptyActivity/template/template.json" to EMPTY_ACTIVITY_TEMPLATE,
+ ),
+ )
+
+ val result = listTemplates(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals(
+ """
+ Code On The Go has 2 project templates installed:
+ No Activity - Creates a no activity
+ Empty Activity - Creates a new empty activity
+ """.trimIndent(),
+ textOf(result),
+ )
+ }
+
+ // Plugins drop their own plugin__*.cgt beside core.cgt.
+ @Test
+ fun `reads templates from every archive in the directory`() {
+ val adb =
+ fakeDevice(
+ listing = "core.cgt\nplugin_demo_extras.cgt\n",
+ members =
+ mapOf(
+ "core.cgt!templates.json" to """{ "templates": [ { "path": "NoActivity" } ] }""",
+ "core.cgt!NoActivity/template/template.json" to NO_ACTIVITY_TEMPLATE,
+ "plugin_demo_extras.cgt!templates.json" to """{ "templates": [ { "path": "EmptyActivity" } ] }""",
+ "plugin_demo_extras.cgt!EmptyActivity/template/template.json" to EMPTY_ACTIVITY_TEMPLATE,
+ ),
+ )
+
+ val result = listTemplates(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(textOf(result).contains("No Activity - Creates a no activity"), textOf(result))
+ assertTrue(textOf(result).contains("Empty Activity - Creates a new empty activity"), textOf(result))
+ }
+
+ // An untrimmed archive name becomes `unzip -p .../core.cgt\r`, which matches
+ // nothing on the device.
+ @Test
+ fun `tolerates the CRLF line endings adb shell emits`() {
+ val adb =
+ fakeDevice(
+ listing = "core.cgt\r\n",
+ members =
+ mapOf(
+ "core.cgt!templates.json" to "{ \"templates\": [ { \"path\": \"NoActivity\" } ] }\r\n",
+ "core.cgt!NoActivity/template/template.json" to NO_ACTIVITY_TEMPLATE.replace("\n", "\r\n"),
+ ),
+ )
+
+ val result = listTemplates(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertEquals(
+ """
+ Code On The Go has 1 project template installed:
+ No Activity - Creates a no activity
+ """.trimIndent(),
+ textOf(result),
+ )
+ assertTrue(adb.commands.none { it.contains("\r") }, adb.commands.toString())
+ }
+
+ // Templates are unpacked during first-run onboarding, so an empty directory is
+ // an answer about the app's state, not a failure to get one.
+ @Test
+ fun `reports a friendly non-error message when no archives are installed`() {
+ val result = listTemplates(fakeDevice(listing = "", members = emptyMap()))
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(textOf(result).contains("No project templates are installed"), textOf(result))
+ assertTrue(textOf(result).contains("onboarding"), textOf(result))
+ }
+
+ // `ls` on an absent directory exits 1, which would otherwise read as adb failing
+ // to reach the device.
+ @Test
+ fun `guards the listing so an absent templates directory still exits zero`() {
+ val adb = fakeDevice(listing = "", members = emptyMap())
+
+ listTemplates(adb)
+
+ assertTrue(adb.commands.single().contains("2>/dev/null || true"), adb.commands.toString())
+ }
+
+ @Test
+ fun `ignores directory entries that are not template archives`() {
+ val adb =
+ fakeDevice(
+ listing = "core.cgt\nREADME.txt\n",
+ members =
+ mapOf(
+ "core.cgt!templates.json" to """{ "templates": [ { "path": "NoActivity" } ] }""",
+ "core.cgt!NoActivity/template/template.json" to NO_ACTIVITY_TEMPLATE,
+ ),
+ )
+
+ val result = listTemplates(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(adb.commands.none { it.contains("README.txt") }, adb.commands.toString())
+ }
+
+ @Test
+ fun `falls back to the archive path when a template declares no name`() {
+ val adb =
+ fakeDevice(
+ listing = "core.cgt\n",
+ members =
+ mapOf(
+ "core.cgt!templates.json" to """{ "templates": [ { "path": "MysteryActivity" } ] }""",
+ "core.cgt!MysteryActivity/template/template.json" to """{ "version": "0.1" }""",
+ ),
+ )
+
+ val result = listTemplates(adb)
+
+ assertEquals(false, result.isError ?: false)
+ assertTrue(textOf(result).contains("MysteryActivity"), textOf(result))
+ }
+
+ @Test
+ fun `reports an error when adb fails`() {
+ val failing = Adb { AdbResult(exitCode = 1, stdout = "", stderr = "adb: no devices/emulators found") }
+
+ val result = listTemplates(failing)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("no devices/emulators found"), textOf(result))
+ }
+
+ @Test
+ fun `reports an error when an archive cannot be read`() {
+ val adb = fakeDevice(listing = "core.cgt\n", members = emptyMap())
+
+ val result = listTemplates(adb)
+
+ assertEquals(true, result.isError)
+ assertTrue(textOf(result).contains("filename not matched"), textOf(result))
+ }
+}