From 141b102fc7e197a4d61bfc3cd4ead37cff980de0 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 00:00:04 -0700 Subject: [PATCH 01/17] ADFA-5083: Design spec for minimal HTTP MCP server Host-side Kotlin/JVM server in a standalone Gradle build at mcp/, using the official MCP Kotlin SDK over Streamable HTTP on loopback. One tool: ping. Versions verified against Maven Central rather than assumed: kotlin-sdk-server 0.15.0 pulls Ktor 3.5.1 and kotlin-stdlib 2.4.0, so the build needs Kotlin >= 2.4.0 - the repo catalog's 2.3.0 would reject the SDK's metadata. Records why .mcp.json stays untouched (a committed http entry pointing at an unstarted process breaks startup for every dev), why loopback gets no TLS, and that root Spotless does reach into top-level standalone dirs, so mcp/ uses tabs. --- .../specs/2026-08-10-mcp-server-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-mcp-server-design.md 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` From ae6b6872a7d9c502ddf4b67879ea8088ff29f238 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 14:16:32 -0700 Subject: [PATCH 02/17] ADFA-5083: Implementation plan for the hello-world MCP server --- .../2026-08-11-mcp-server-hello-world.md | 566 ++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md 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..a9e6fb932a --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md @@ -0,0 +1,566 @@ +# 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.** The bare shell has JDK 21; flox supplies JDK 17. From `mcp/`, that is `flox activate -d ../flox/local -- ./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) + +The SDK's README on `main` is **ahead of the 0.15.0 release**. Use these signatures, confirmed via `javap` against `kotlin-sdk-server-jvm-0.15.0.jar`: + +- **The tool handler takes TWO parameters**, not one: `suspend (ClientConnection, CallToolRequest) -> CallToolResult`. The README's single-parameter `{ request -> ... }` will not compile. +- `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), `io.ktor:ktor-client-sse:3.5.1` (test), and `kotlin("test")` on the classpath. `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") + + testImplementation(kotlin("test")) + testImplementation("io.modelcontextprotocol:kotlin-sdk-client:0.15.0") + testImplementation("io.ktor:ktor-client-cio:3.5.1") + testImplementation("io.ktor:ktor-client-sse: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/mcp +flox activate -d ../flox/local -- ./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/mcp +flox activate -d ../flox/local -- ./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's two parameters are both unused here; that is the real 0.15.0 signature (`ClientConnection`, `CallToolRequest`), and the README's one-parameter form does not compile. + +```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)), + ), + ) + + 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/mcp +flox activate -d ../flox/local -- ./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. It is installed in the test above; if the error persists, confirm `io.ktor:ktor-client-sse:3.5.1` is on the test classpath. +- 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/mcp +flox activate -d ../flox/local -- ./gradlew run & +sleep 15 +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 + +```bash +# from mcp/ +flox activate -d ../flox/local -- ./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 +flox activate -d ../flox/local -- ./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/mcp +flox activate -d ../flox/local -- ./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 -- ./gradlew run` from `mcp/` 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. From 1e8ee284c8749d2cf08b477529149590ddec79a1 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 14:30:36 -0700 Subject: [PATCH 03/17] 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. The standalone build keeps that off the root catalog entirely - mcp/ is absent from settings.gradle.kts, the same way apk-viewer-plugin is. SdkResolutionTest exists to make that floor fail loudly and on its own, rather than surfacing later tangled up in a protocol error. --- mcp/.gitignore | 2 + mcp/build.gradle.kts | 33 +++ mcp/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes mcp/gradle/wrapper/gradle-wrapper.properties | 7 + mcp/gradlew | 249 ++++++++++++++++++ mcp/gradlew.bat | 92 +++++++ mcp/settings.gradle.kts | 1 + .../androidide/mcp/SdkResolutionTest.kt | 14 + 8 files changed, 398 insertions(+) create mode 100644 mcp/.gitignore create mode 100644 mcp/build.gradle.kts create mode 100755 mcp/gradle/wrapper/gradle-wrapper.jar create mode 100644 mcp/gradle/wrapper/gradle-wrapper.properties create mode 100755 mcp/gradlew create mode 100755 mcp/gradlew.bat create mode 100644 mcp/settings.gradle.kts create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/SdkResolutionTest.kt 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/build.gradle.kts b/mcp/build.gradle.kts new file mode 100644 index 0000000000..54cb58bf58 --- /dev/null +++ b/mcp/build.gradle.kts @@ -0,0 +1,33 @@ +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") +} + +// Under flox the running JVM is already 17, so this resolves locally. Outside flox +// it fails with an explicit toolchain error instead of silently building on JDK 21. +kotlin { + jvmToolchain(17) +} + +application { + mainClass.set("com.itsaky.androidide.mcp.MainKt") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/mcp/gradle/wrapper/gradle-wrapper.jar b/mcp/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/mcp/gradle/wrapper/gradle-wrapper.properties b/mcp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..692c2dc230 --- /dev/null +++ b/mcp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/mcp/gradlew b/mcp/gradlew new file mode 100755 index 0000000000..1aa94a4269 --- /dev/null +++ b/mcp/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mcp/gradlew.bat b/mcp/gradlew.bat new file mode 100755 index 0000000000..6689b85bee --- /dev/null +++ b/mcp/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mcp/settings.gradle.kts b/mcp/settings.gradle.kts new file mode 100644 index 0000000000..68f32d7940 --- /dev/null +++ b/mcp/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "cogo-mcp" 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) + } +} From 62a9803b68fe068deaae2ae4c71ba84ad99e3fdb Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 14:30:36 -0700 Subject: [PATCH 04/17] ADFA-5083: Correct two plan errors found while scaffolding Both were wrong in a way that would have cost the next reader time: - flox activate -d ../flox/local from inside mcp/ fails outright. The env's on-activate hook aborts unless activated from the repo root, so every Gradle command becomes: flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew X' - io.ktor:ktor-client-sse does not exist at any version. The client SSE plugin ships inside ktor-client-core, which arrives transitively via kotlin-sdk-client. --- .../2026-08-11-mcp-server-hello-world.md | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) 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 index a9e6fb932a..9ad3ba9ddf 100644 --- a/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md +++ b/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md @@ -17,7 +17,12 @@ - **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.** The bare shell has JDK 21; flox supplies JDK 17. From `mcp/`, that is `flox activate -d ../flox/local -- ./gradlew `. +- **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. @@ -72,7 +77,7 @@ Isolates the single riskiest thing in this plan — the Kotlin 2.4.0 metadata fl **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), `io.ktor:ktor-client-sse:3.5.1` (test), and `kotlin("test")` on the classpath. `application { mainClass = "com.itsaky.androidide.mcp.MainKt" }`. +- 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** @@ -113,10 +118,11 @@ 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") - testImplementation("io.ktor:ktor-client-sse:3.5.1") } kotlin { @@ -163,8 +169,8 @@ class SdkResolutionTest { - [ ] **Step 6: Run the test** ```bash -cd /Users/eisen/src/CodeOnTheGo/mcp -flox activate -d ../flox/local -- ./gradlew test --tests '*SdkResolutionTest*' +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. @@ -278,8 +284,8 @@ class PingTest { - [ ] **Step 2: Run the test to verify it fails** ```bash -cd /Users/eisen/src/CodeOnTheGo/mcp -flox activate -d ../flox/local -- ./gradlew test --tests '*PingTest*' +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. @@ -349,22 +355,22 @@ fun main(args: Array) { - [ ] **Step 5: Run the tests to verify they pass** ```bash -cd /Users/eisen/src/CodeOnTheGo/mcp -flox activate -d ../flox/local -- ./gradlew test +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. It is installed in the test above; if the error persists, confirm `io.ktor:ktor-client-sse:3.5.1` is on the test classpath. +- `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/mcp -flox activate -d ../flox/local -- ./gradlew run & -sleep 15 +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' \ @@ -413,9 +419,12 @@ 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 mcp/ -flox activate -d ../flox/local -- ./gradlew run +# 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 @@ -427,7 +436,8 @@ non-loopback interface would require both TLS and authentication first. ## Test ```bash -flox activate -d ../flox/local -- ./gradlew test +# from the repo root +flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew test' ``` ## Register with an MCP client @@ -460,8 +470,8 @@ once you are actually using it: - [ ] **Step 2: Full verification from a clean build** ```bash -cd /Users/eisen/src/CodeOnTheGo/mcp -flox activate -d ../flox/local -- ./gradlew clean test +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 ``` @@ -552,7 +562,7 @@ jira issue comment add ADFA-5083 "PR opened into stage. Hello-world server is gr ## Definition of Done -- [ ] `flox activate -d ../flox/local -- ./gradlew run` from `mcp/` serves `127.0.0.1:3000/mcp` +- [ ] `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 From 4a14ffd8c0aa3e1dd6cbc8170b62c3f5b1286b73 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 14:33:49 -0700 Subject: [PATCH 05/17] 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. The transport is the only thing this change actually adds, so testing anything less would prove nothing. Verified independently with raw curl against a running server: the handshake returns serverInfo cogo-mcp, tools/list returns ping, and tools/call returns pong. cogoMcpServer() is split out from main() so the test mounts the identical server the entrypoint does, without main()'s wait = true blocking the suite. Binds 127.0.0.1 only - the server is unauthenticated. Tests use port 0 so the suite never collides with a server running on 3000. --- .../itsaky/androidide/mcp/CogoMcpServer.kt | 36 ++++++++++ .../kotlin/com/itsaky/androidide/mcp/Main.kt | 16 +++++ .../com/itsaky/androidide/mcp/PingTest.kt | 68 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/Main.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt 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..5a9b781275 --- /dev/null +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -0,0 +1,36 @@ +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 +} 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/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..a35d67042d --- /dev/null +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt @@ -0,0 +1,68 @@ +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.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 + +class PingTest { + // Port 0 so the suite never collides with a real server on 3000. + 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) + } +} From 2a12a3fecafcad646177cb618a83e71447c96ca6 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 14:33:49 -0700 Subject: [PATCH 06/17] ADFA-5083: Correct the tool-handler signature in the plan javap reports the handler as Function3, but ClientConnection is a Kotlin receiver compiled to a leading JVM argument - the lambda takes one parameter, not two. Caught by the compiler while implementing. --- .../plans/2026-08-11-mcp-server-hello-world.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 9ad3ba9ddf..98d0b62041 100644 --- a/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md +++ b/docs/superpowers/plans/2026-08-11-mcp-server-hello-world.md @@ -31,9 +31,9 @@ ### Verified API reference (from the 0.15.0 jars, not the README) -The SDK's README on `main` is **ahead of the 0.15.0 release**. Use these signatures, confirmed via `javap` against `kotlin-sdk-server-jvm-0.15.0.jar`: +Signatures confirmed via `javap` against `kotlin-sdk-server-jvm-0.15.0.jar`, then verified by compiling against them: -- **The tool handler takes TWO parameters**, not one: `suspend (ClientConnection, CallToolRequest) -> CallToolResult`. The README's single-parameter `{ request -> ... }` will not compile. +- **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 = ..., ...)` @@ -292,7 +292,7 @@ Expected: **compilation failure**, `Unresolved reference: cogoMcpServer`. That i - [ ] **Step 3: Write `CogoMcpServer.kt`** -The handler's two parameters are both unused here; that is the real 0.15.0 signature (`ClientConnection`, `CallToolRequest`), and the README's one-parameter form does not compile. +The handler is a lambda with `ClientConnection` as receiver and `CallToolRequest` as its single parameter; `ping` uses neither. ```kotlin package com.itsaky.androidide.mcp @@ -319,11 +319,13 @@ fun cogoMcpServer(): Server { ), ) + // 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"))) } From 940a10b951baaa3d7310bd871e3609779cfb85ad Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 14:34:55 -0700 Subject: [PATCH 07/17] ADFA-5083: Document how to run and register the MCP server --- mcp/README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 mcp/README.md diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000000..e7805e4aac --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,64 @@ +# 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' +``` + +`PingTest` starts the server on an ephemeral port and drives it with the SDK's +own MCP client over Streamable HTTP - initialize, tools/list, tools/call. + +## 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. +- There is no `ktor-client-sse` artifact at any version. The client SSE plugin + lives in `ktor-client-core`, which arrives transitively via + `kotlin-sdk-client`. +- A tool handler is `suspend ClientConnection.(CallToolRequest) -> CallToolResult`. + `ClientConnection` is the receiver, not a parameter. +- Root Spotless **does** format this directory. Use tabs, and run + `./gradlew spotlessApply` from the **repo root**, not from here. From f55a6a1842830ca8af65799a1f2c266d2f0841e2 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 15:03:10 -0700 Subject: [PATCH 08/17] ADFA-5083: Add is_cogo_installed, the first adb-backed tool Probes whether Code On The Go is installed on the attached device. First step toward CoGo-awareness; the tool surface stays deliberately small. Adb is a fun interface over the process boundary so the tool's logic is tested without a device, and SystemAdb is the one real implementation. SystemAdb is covered by running /bin/echo and /bin/sh rather than adb itself, so the adapter is genuinely exercised in CI without an emulator attached. A failed adb call reports isError rather than "not installed" - not knowing is not the same as knowing it is absent, and collapsing the two would make a missing device look like a missing app. Two parsing hazards are pinned by tests: pm list packages matches substrings, so com.itsaky.androidide.debug must not satisfy a query for com.itsaky.androidide; and adb shell emits CRLF, so an untrimmed compare would silently never match. Verified against emulator-5554 through the real MCP transport, not just fakes: tools/call is_cogo_installed returns "is installed", matching adb shell pm list packages. Test fixture extracted from PingTest so both suites share one server-and-client harness. --- .../kotlin/com/itsaky/androidide/mcp/Adb.kt | 33 +++++++++ .../itsaky/androidide/mcp/CogoMcpServer.kt | 43 +++++++++++- .../androidide/mcp/CogoInstalledTest.kt | 68 +++++++++++++++++++ .../itsaky/androidide/mcp/McpTestFixture.kt | 42 ++++++++++++ .../com/itsaky/androidide/mcp/PingTest.kt | 54 ++++----------- .../itsaky/androidide/mcp/SystemAdbTest.kt | 24 +++++++ 6 files changed, 221 insertions(+), 43 deletions(-) create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoInstalledTest.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/McpTestFixture.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/SystemAdbTest.kt diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt new file mode 100644 index 0000000000..545cb3fe11 --- /dev/null +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.mcp + +data class AdbResult( + val exitCode: Int, + val stdout: String, + val stderr: String, +) + +fun interface Adb { + fun run(args: List): AdbResult +} + +class SystemAdb( + private val executable: String = "adb", +) : Adb { + override fun run(args: List): AdbResult { + val process = ProcessBuilder(listOf(executable) + args).start() + + // Drain stderr on its own thread: filling one pipe buffer while the other + // goes unread deadlocks the child. + val stderr = StringBuilder() + val drain = + Thread { + process.errorStream.bufferedReader().forEachLine { stderr.appendLine(it) } + } + drain.start() + + val stdout = process.inputStream.bufferedReader().readText() + drain.join() + + return AdbResult(exitCode = process.waitFor(), stdout = stdout, stderr = stderr.toString()) + } +} diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt index 5a9b781275..92eabc5aa1 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -11,8 +11,9 @@ import kotlinx.serialization.json.JsonObject const val SERVER_NAME = "cogo-mcp" const val SERVER_VERSION = "0.1.0" +const val COGO_PACKAGE = "com.itsaky.androidide" -fun cogoMcpServer(): Server { +fun cogoMcpServer(adb: Adb = SystemAdb()): Server { val server = Server( serverInfo = Implementation(name = SERVER_NAME, version = SERVER_VERSION), @@ -32,5 +33,45 @@ fun cogoMcpServer(): Server { CallToolResult(content = listOf(TextContent("pong"))) } + server.addTool( + name = "is_cogo_installed", + description = "Report whether Code On The Go ($COGO_PACKAGE) is installed on the attached device.", + inputSchema = ToolSchema(properties = JsonObject(emptyMap())), + ) { _ -> + isCogoInstalled(adb) + } + return server } + +private fun isCogoInstalled(adb: Adb): CallToolResult { + val result = adb.run(listOf("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) { + val detail = result.stderr.trim().ifEmpty { result.stdout.trim() } + return CallToolResult( + content = listOf(TextContent("adb failed (exit ${result.exitCode}): $detail")), + isError = true, + ) + } + + // adb shell emits 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/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 index a35d67042d..6081fad179 100644 --- a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt @@ -1,62 +1,32 @@ 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.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 class PingTest { - // Port 0 so the suite never collides with a real server on 3000. - 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 -> + withConnectedClient({ cogoMcpServer() }) { 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 }) + fun `tools list contains exactly the registered tools`() = + withConnectedClient({ cogoMcpServer() }) { client -> + assertEquals( + setOf("ping", "is_cogo_installed"), + client + .listTools() + .tools + .map { it.name } + .toSet(), + ) } @Test fun `calling ping returns pong`() = - withConnectedClient { client -> + withConnectedClient({ cogoMcpServer() }) { client -> val result = client.callTool(name = "ping", arguments = emptyMap()) val text = result.content 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()) + } +} From 03515b0453c14b3c97b957ef9dfdc6babeb4a896 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 15:04:01 -0700 Subject: [PATCH 09/17] ADFA-5083: Document is_cogo_installed and the Adb testing seam --- mcp/README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index e7805e4aac..a67224b499 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -3,8 +3,16 @@ 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. +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. | + +`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. ## Run @@ -29,8 +37,13 @@ non-loopback interface would require both TLS and authentication first. flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew test' ``` -`PingTest` starts the server on an ephemeral port and drives it with the SDK's -own MCP client over Streamable HTTP - initialize, tools/list, tools/call. +Tests drive the server through the real MCP client over Streamable HTTP, not by +calling handlers directly. No emulator or device is required: `Adb` is a `fun +interface` over the process boundary, so tool logic is tested with fakes, and +`SystemAdb` is exercised against `/bin/echo` and `/bin/sh`. + +Adding a tool that shells out? Take `Adb` as a parameter rather than calling +`ProcessBuilder` directly, or it will not be testable without a device. ## Register with an MCP client From 32bdf77f7192528044b6f00fe71d9012c411996c Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 15:30:32 -0700 Subject: [PATCH 10/17] ADFA-5083: Make the server self-describing tools/list already listed every tool, but initialize said almost nothing about what the server was for. An agent had to infer the whole purpose from two tool descriptions. - initialize now returns instructions: what the server drives, that tools act on adb's default device, and that an adb failure is not a negative answer. - Every tool carries a title alongside name and description. - listChanged drops from true to false. The tool set is fixed at construction, so the old value promised a notifications/tools/list_changed that would never arrive - advertising a capability we do not implement. Tool descriptions also now say when to reach for the tool, not just what it does; that string is the only such signal an agent gets. ServerDescriptionTest pins all three so they cannot silently rot, and so a new tool added without a title fails the build rather than shipping unlabelled. --- mcp/README.md | 18 +++++++++ .../itsaky/androidide/mcp/CogoMcpServer.kt | 24 ++++++++++-- .../androidide/mcp/ServerDescriptionTest.kt | 39 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt diff --git a/mcp/README.md b/mcp/README.md index a67224b499..dbc6eec75d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -14,6 +14,24 @@ The tool surface is deliberately small and grows one tool at a time. 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. +## Self-description + +The server describes itself to clients, and `ServerDescriptionTest` enforces it +rather than leaving it to review: + +- `initialize` returns **instructions** explaining that the server drives CoGo + over adb, that tools act on adb's default device, and that an adb failure is + not a negative answer. +- Every tool carries a **`title`** as well as a `name` and `description`. +- `listChanged` is advertised as **`false`**. The tool set is fixed at + construction, so claiming otherwise would promise a + `notifications/tools/list_changed` that never arrives. + +A tool's `description` is the only signal an agent gets about *when* to reach +for it, so it carries more weight than its length suggests. Adding a tool means +adding a title and a description that says when to use it - the test will fail +otherwise. + ## Run The flox environment's `on-activate` hook aborts unless it is activated from diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt index 92eabc5aa1..56c840e9a6 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -13,21 +13,36 @@ const val SERVER_NAME = "cogo-mcp" const val SERVER_VERSION = "0.1.0" const val COGO_PACKAGE = "com.itsaky.androidide" +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() + fun cogoMcpServer(adb: Adb = SystemAdb()): Server { val server = Server( serverInfo = Implementation(name = SERVER_NAME, version = SERVER_VERSION), options = ServerOptions( - capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)), + // 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. ping uses neither. server.addTool( name = "ping", - description = "Health check. Returns pong.", + title = "Ping", + description = "Health check. Returns pong. Does not touch the device.", inputSchema = ToolSchema(properties = JsonObject(emptyMap())), ) { _ -> CallToolResult(content = listOf(TextContent("pong"))) @@ -35,7 +50,10 @@ fun cogoMcpServer(adb: Adb = SystemAdb()): Server { server.addTool( name = "is_cogo_installed", - description = "Report whether Code On The Go ($COGO_PACKAGE) is installed on the attached device.", + 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 = ToolSchema(properties = JsonObject(emptyMap())), ) { _ -> isCogoInstalled(adb) 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..77782ec878 --- /dev/null +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt @@ -0,0 +1,39 @@ +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?", + ), + client.listTools().tools.associate { it.name to it.title }, + ) + } +} From 4f02aae391de43a2de328614be4e5bb15b5f96b2 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 17:44:00 -0700 Subject: [PATCH 11/17] ADFA-5083: Add cogo_home, which navigates to the IDE home screen Launching the app is not enough to reach home. MainActivity.onCreate calls tryOpenLastProject(), and autoOpenProjects defaults to true, so the real path is Splash -> Onboarding -> MainActivity -> Editor. Clearing ide_last_project does not help either: tryOpenLastProject() falls back to the most recently modified project, so it opens something regardless. Only the boolean 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), rewrites that one preference via run-as, relaunches MainActivity by explicit component, and polls dumpsys until the resumed activity really is MainActivity. If it ends up in the editor it says so and returns isError, rather than claiming success it cannot back. Two costs are stated plainly in the tool description: it force-stops the app, and the preference change persists. The XML edit lives in Kotlin (withAutoOpenDisabled), not in on-device sed, because sed could not be tested. The first version used sed '/KEY/d' and corrupted the file on its second run - a previous run had left sharing a line with the boolean, so deleting the line deleted the tag. Fake-based tests were green throughout; only running it twice against a real emulator exposed it. The pure function has an idempotency test that would have caught it. The poll budget also went from 10 to 30 attempts: a cold start after force-stop measured about 6s, and the old 4.5s budget reported a false failure. Launch uses the explicit component because debug builds ship a second LAUNCHER activity (LeakCanary), which makes monkey -c LAUNCHER ambiguous. Verified twice in a row against emulator-5554: both runs reach home, the prefs file stays valid, survives, and the key appears exactly once. --- mcp/README.md | 29 ++++ mcp/TODO.txt | 23 +++ .../itsaky/androidide/mcp/CogoMcpServer.kt | 139 +++++++++++++++++- .../androidide/mcp/AutoOpenPreferenceTest.kt | 62 ++++++++ .../com/itsaky/androidide/mcp/CogoHomeTest.kt | 131 +++++++++++++++++ .../com/itsaky/androidide/mcp/PingTest.kt | 2 +- .../androidide/mcp/ServerDescriptionTest.kt | 1 + 7 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 mcp/TODO.txt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/AutoOpenPreferenceTest.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/CogoHomeTest.kt diff --git a/mcp/README.md b/mcp/README.md index dbc6eec75d..16ae9cfd3f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -9,11 +9,40 @@ The tool surface is deliberately small and grows one tool at a time. |---|---|---| | `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. | `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 `` tag, which shared a +line with the boolean. A fake cannot round-trip a file; only a pure function can. + +The explicit component is also deliberate: debug builds ship a second LAUNCHER +activity (LeakCanary), so `monkey -c LAUNCHER` is ambiguous. + ## Self-description The server describes itself to clients, and `ServerDescriptionTest` enforces it diff --git a/mcp/TODO.txt b/mcp/TODO.txt new file mode 100644 index 0000000000..51b66ed436 --- /dev/null +++ b/mcp/TODO.txt @@ -0,0 +1,23 @@ +Navigate to new project activity +Navigate to Open saved project activity +Navigate to Clone a git project activity +Navigate to Delete a saved project activity +Navigate to Home Termux +Navigate to Main Preferences +Navigate to General Preferences +Navigate to Editor Preferences +Navigate to Build & Run Preferences +Navigate to Terminal Preferences +Navigate to Git Preferences +Navigate to About Cogo +Navigate to Plugin Manager +Navigate to Developer Options +Navigate to Main Help +Move feedback button (takes 9 options: north, northeast, east, southeast, south, southwest, west, northwest, center) # like a tictactoe board +List available project templates +List available pre-existing projects +Open a specific project +Create a project from a template +Open project left drawer +Open project bottom drawer +List files in current project diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt index 56c840e9a6..28c4d5f0ef 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -7,7 +7,9 @@ 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.coroutines.delay import kotlinx.serialization.json.JsonObject +import java.util.Base64 const val SERVER_NAME = "cogo-mcp" const val SERVER_VERSION = "0.1.0" @@ -25,7 +27,62 @@ private val SERVER_INSTRUCTIONS = not the same as an answer: it does not mean the app is absent. """.trimIndent() -fun cogoMcpServer(adb: Adb = SystemAdb()): Server { +const val MAIN_ACTIVITY = "$COGO_PACKAGE/.activities.MainActivity" + +private const val AUTO_OPEN_KEY = "idepref_general_autoOpenProjects" + +private const val PREFS_PATH = "shared_prefs/${COGO_PACKAGE}_preferences.xml" + +private const val AUTO_OPEN_ELEMENT = """""" + +private const val PREFS_HEADER = """""" + +/** + * Returns [existingXml] with auto-open-project disabled, creating the document if + * it is absent. + * + * tryOpenLastProject() falls back to the most recently modified project when no + * last project is recorded, so clearing ide_last_project would not be enough -- + * this boolean is the only thing that reliably prevents the jump to the editor. + * + * The edit is element-wise, not line-wise, and so is safe to repeat: an earlier + * version deleted whole lines on-device and destroyed the `` tag whenever a + * previous run had left it sharing a line with the boolean. + */ +fun withAutoOpenDisabled(existingXml: String): String { + val body = existingXml.trim() + if (body.isEmpty() || !body.contains("\n $AUTO_OPEN_ELEMENT\n\n" + } + + val updated = + body + .replace(Regex(""""""), "\n") + .replace(Regex("""\s*]*/>"""), "") + .replace("", " $AUTO_OPEN_ELEMENT\n") + + // 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" +} + +// The prefs file is absent until the app first writes one, and that must read as +// empty rather than as an adb failure - hence the `|| true`. +private fun readPreferencesCommand() = "run-as $COGO_PACKAGE sh -c \"cat $PREFS_PATH 2>/dev/null || true\"" + +// 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 "run-as $COGO_PACKAGE sh -c \"mkdir -p shared_prefs && echo $encoded | base64 -d > $PREFS_PATH\"" +} + +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), @@ -59,20 +116,90 @@ fun cogoMcpServer(adb: Adb = SystemAdb()): Server { 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 = ToolSchema(properties = JsonObject(emptyMap())), + ) { _ -> + cogoHome(adb, homePollAttempts, homePollDelayMillis) + } + return server } +private 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.run(listOf("shell", "am", "force-stop", COGO_PACKAGE)).let { + if (it.exitCode != 0) return adbFailure(it) + } + val existingPrefs = adb.run(listOf("shell", readPreferencesCommand())) + if (existingPrefs.exitCode != 0) { + return adbFailure(existingPrefs) + } + adb.run(listOf("shell", writePreferencesCommand(withAutoOpenDisabled(existingPrefs.stdout)))).let { + if (it.exitCode != 0) return adbFailure(it) + } + adb.run(listOf("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.run(listOf("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("/") } + +private fun adbFailure(result: AdbResult): CallToolResult { + val detail = result.stderr.trim().ifEmpty { result.stdout.trim() } + return CallToolResult( + content = listOf(TextContent("adb failed (exit ${result.exitCode}): $detail")), + isError = true, + ) +} + private fun isCogoInstalled(adb: Adb): CallToolResult { val result = adb.run(listOf("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) { - val detail = result.stderr.trim().ifEmpty { result.stdout.trim() } - return CallToolResult( - content = listOf(TextContent("adb failed (exit ${result.exitCode}): $detail")), - isError = true, - ) + return adbFailure(result) } // adb shell emits CRLF, so trim before comparing. 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/PingTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt index 6081fad179..5da0b2cd76 100644 --- a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt @@ -15,7 +15,7 @@ class PingTest { fun `tools list contains exactly the registered tools`() = withConnectedClient({ cogoMcpServer() }) { client -> assertEquals( - setOf("ping", "is_cogo_installed"), + setOf("ping", "is_cogo_installed", "cogo_home"), client .listTools() .tools diff --git a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt index 77782ec878..657afd37b0 100644 --- a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt @@ -32,6 +32,7 @@ class ServerDescriptionTest { mapOf( "ping" to "Ping", "is_cogo_installed" to "Is Code On The Go installed?", + "cogo_home" to "Go to Code On The Go home", ), client.listTools().tools.associate { it.name to it.title }, ) From 7e5382a9e4d8568a01126dfa06951a6fefc1931e Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 18:17:05 -0700 Subject: [PATCH 12/17] ADFA-5083: Stop debug builds registering two launcher activities LeakCanary declares leakcanary.internal.activity.LeakLauncherActivity as a second MAIN/LAUNCHER activity-alias, so every debug build advertised two launcher entries. Any generic launch resolved to the system ResolverActivity instead of the IDE: cmd package resolve-activity -a MAIN -c LAUNCHER com.itsaky.androidide -> android/com.android.internal.app.ResolverActivity That breaks anything driving the app generically - monkey -c LAUNCHER, tapping the icon, UI automation - and debug builds are the norm for on-device tooling. LeakCanary gates the alias on android:enabled="@bool/leak_canary_add_launcher_icon", so overriding that boolean in the debug source set is the sanctioned fix; no manifest-merger override is needed. Leak reports are unaffected. Only the launcher alias is disabled; LeakActivity stays registered and is still reachable from LeakCanary's notification. Verified on emulator-5554 after rebuild and reinstall: resolve-activity ResolverActivity -> .activities.SplashActivity enabled LAUNCHER activities 2 -> 1 (SplashActivity only) monkey -p PKG -c LAUNCHER now launches the IDE LeakActivity still registered --- app/src/debug/res/values/leakcanary.xml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/src/debug/res/values/leakcanary.xml 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 + From 90a41eac9813338bf6b54e8b06cc512515008abd Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 18:59:40 -0700 Subject: [PATCH 13/17] ADFA-5083: Add scored tool priorities and share adbFailure PRIORITIES.md supersedes TODO.txt: the rubric, all 25 items scored, and the reachability facts measured against a real emulator rather than read off the source. Three list corrections came out of that - items 1-4 are fragments not activities, item 5 must target com.termux.app.TermuxActivity, and item 19 cannot use an intent at all. The headline is that the top three items need no UI whatsoever. adbFailure becomes internal so every adb-backed tool shares one definition of 'a failed call is not a negative answer'. --- mcp/PRIORITIES.md | 169 ++++++++++++++++++ .../itsaky/androidide/mcp/CogoMcpServer.kt | 4 +- 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 mcp/PRIORITIES.md diff --git a/mcp/PRIORITIES.md b/mcp/PRIORITIES.md new file mode 100644 index 0000000000..472e8c5f20 --- /dev/null +++ b/mcp/PRIORITIES.md @@ -0,0 +1,169 @@ +# 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. + +## 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. + +## 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. + +If only two were possible: 1 and 2. Reachability plus verifiability is the whole +problem. diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt index 28c4d5f0ef..0190af51a1 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -185,7 +185,9 @@ private fun resumedActivity(dumpsys: String): String? = ?.split(" ", "}") ?.firstOrNull { it.contains("/") } -private fun adbFailure(result: AdbResult): CallToolResult { +// Shared by every adb-backed tool: a failed call means we do not know, which is +// never the same as a negative answer. +internal fun adbFailure(result: AdbResult): CallToolResult { val detail = result.stderr.trim().ifEmpty { result.stdout.trim() } return CallToolResult( content = listOf(TextContent("adb failed (exit ${result.exitCode}): $detail")), From 38f9fdf30c902089308628f03af11b3ac9e2fba7 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 19:21:15 -0700 Subject: [PATCH 14/17] ADFA-5083: Add list_projects, list_templates and list_project_files The three highest-scoring items in PRIORITIES.md, built in parallel. None of them drives the UI - they are adb shell reads - which is exactly why they ranked top: highest agent value and lowest cost at the same time. list_projects mirrors ProjectValidations.kt one level deep, so it returns only what the IDE would actually open. Stray .cgt archives and Flutter directories are excluded; on the test device that is 2 of 5 entries, and both surviving project names contain spaces. The whole filter runs in one shell command so no filename ever crosses the adb boundary and has to be re-quoted to survive. list_templates reads the .cgt archives in place with unzip -p rather than pulling 1.6MB. template.json is not strict JSON - it carries unquoted keys like {identifier: "APP_NAME"} - and the corruption is inconsistent across templates, so a JSON parser would have worked on some and thrown on others. Regex it is. list_project_files resolves the open project from the last-opened-project preference, then lists it. run-as cannot read /storage/emulated/0, so only the preference read uses run-as and the find runs as the shell user. The sentinel also comes back XML-escaped, so both spellings are handled. All three report "no data" distinctly from "adb failed": an empty projects dir, an un-onboarded device, and no open project are answers, not failures. 69 tests, 0 failures. Verified through the real MCP transport against emulator-5554: 2 projects, 9 templates, and a correct non-error "no project open". --- mcp/README.md | 7 + mcp/TODO.txt | 2 + .../itsaky/androidide/mcp/CogoMcpServer.kt | 40 +++ .../com/itsaky/androidide/mcp/ProjectFiles.kt | 124 +++++++ .../com/itsaky/androidide/mcp/Projects.kt | 86 +++++ .../com/itsaky/androidide/mcp/Templates.kt | 125 ++++++++ .../com/itsaky/androidide/mcp/PingTest.kt | 9 +- .../itsaky/androidide/mcp/ProjectFilesTest.kt | 215 +++++++++++++ .../com/itsaky/androidide/mcp/ProjectsTest.kt | 229 +++++++++++++ .../androidide/mcp/ServerDescriptionTest.kt | 3 + .../itsaky/androidide/mcp/TemplatesTest.kt | 303 ++++++++++++++++++ 11 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectFilesTest.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/ProjectsTest.kt create mode 100644 mcp/src/test/kotlin/com/itsaky/androidide/mcp/TemplatesTest.kt diff --git a/mcp/README.md b/mcp/README.md index 16ae9cfd3f..a081825552 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -10,6 +10,13 @@ The tool surface is deliberately small and grows one tool at a time. | `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 diff --git a/mcp/TODO.txt b/mcp/TODO.txt index 51b66ed436..ebfa1fb20d 100644 --- a/mcp/TODO.txt +++ b/mcp/TODO.txt @@ -21,3 +21,5 @@ Create a project from a template Open project left drawer Open project bottom drawer List files in current project +Save the current project +Close the current project diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt index 0190af51a1..44977a576d 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -129,6 +129,46 @@ fun cogoMcpServer( 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 = ToolSchema(properties = JsonObject(emptyMap())), + ) { _ -> + 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 = ToolSchema(properties = JsonObject(emptyMap())), + ) { _ -> + 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 = ToolSchema(properties = JsonObject(emptyMap())), + ) { _ -> + listProjectFiles(adb) + } + return server } 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..2ddfa1e2b9 --- /dev/null +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.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 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 const val PROJECT_PREFS_PATH = "shared_prefs/${COGO_PACKAGE}_preferences.xml" + +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 &lt; does not become a tag. + .replace("&", "&") + +// The prefs file does not exist until the app writes one, and that must read as +// empty rather than as an adb failure - hence the `|| true`. +private fun readProjectPreferencesCommand() = "run-as $COGO_PACKAGE sh -c \"cat $PROJECT_PREFS_PATH 2>/dev/null || true\"" + +// 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.run(listOf("shell", readProjectPreferencesCommand())) + if (prefs.exitCode != 0) { + return adbFailure(prefs) + } + + val projectPath = + parseLastOpenedProject(prefs.stdout) + ?: return CallToolResult(content = listOf(TextContent(NO_PROJECT_MESSAGE))) + + val listing = adb.run(listOf("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..3d718ce7b4 --- /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.run(listOf("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..7fda328bcb --- /dev/null +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt @@ -0,0 +1,125 @@ +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 const val LIST_ARCHIVES_COMMAND = + "run-as $COGO_PACKAGE sh -c \"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, +) = "run-as $COGO_PACKAGE sh -c \"unzip -p $TEMPLATES_DIR/$archive $member\"" + +fun listTemplates(adb: Adb): CallToolResult { + val listing = adb.run(listOf("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.run(listOf("shell", readMemberCommand(archive, INDEX_MEMBER))) + if (index.exitCode != 0) { + return adbFailure(index) + } + for (path in parseTemplateIndex(index.stdout)) { + val template = adb.run(listOf("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/PingTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt index 5da0b2cd76..b91ee0eac4 100644 --- a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/PingTest.kt @@ -15,7 +15,14 @@ class PingTest { fun `tools list contains exactly the registered tools`() = withConnectedClient({ cogoMcpServer() }) { client -> assertEquals( - setOf("ping", "is_cogo_installed", "cogo_home"), + setOf( + "ping", + "is_cogo_installed", + "cogo_home", + "list_projects", + "list_templates", + "list_project_files", + ), client .listTools() .tools 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/ServerDescriptionTest.kt b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt index 657afd37b0..0cd2a8ab9e 100644 --- a/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt +++ b/mcp/src/test/kotlin/com/itsaky/androidide/mcp/ServerDescriptionTest.kt @@ -33,6 +33,9 @@ class ServerDescriptionTest { "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/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)) + } +} From c41db8f417f7f1a04d1947d86946c311597de5ff Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 19:31:59 -0700 Subject: [PATCH 15/17] ADFA-5083: Split tools into one file each and share the adb idioms Refactor only - no behaviour change, and the test files are untouched, which is what proves it. The three list tools arrived one-per-file with pure parsing functions, but CogoMcpServer.kt had grown to 264 lines holding server construction, tool registration, cogo_home and is_cogo_installed. Bring the older code in line: CogoHome.kt and CogoInstalled.kt now sit beside Projects.kt, Templates.kt and ProjectFiles.kt, and CogoMcpServer.kt is registration only. The duplication being collapsed is real, not anticipated. PREFS_PATH and the preferences-read command were byte-identical in two files, the run-as ... sh -c wrapper was hand-built in five places, and run(listOf("shell", ...)) appeared at a dozen call sites. Those move to CogoDevice.kt and Adb.shell(). Deliberately not collapsed: Projects.kt trims line ends while the others trim both. That reads as duplication but the difference is load-bearing - trimEnd protects a project name with a leading space. Verified against emulator-5554 after the refactor, since fakes cannot prove command construction: all six tools return what they did before, and cogo_home still preserves an unrelated preference while writing its own exactly once. --- .../kotlin/com/itsaky/androidide/mcp/Adb.kt | 21 +++ .../com/itsaky/androidide/mcp/CogoDevice.kt | 19 +++ .../com/itsaky/androidide/mcp/CogoHome.kt | 104 ++++++++++++ .../itsaky/androidide/mcp/CogoInstalled.kt | 34 ++++ .../itsaky/androidide/mcp/CogoMcpServer.kt | 157 +----------------- .../com/itsaky/androidide/mcp/ProjectFiles.kt | 10 +- .../com/itsaky/androidide/mcp/Projects.kt | 2 +- .../com/itsaky/androidide/mcp/Templates.kt | 11 +- 8 files changed, 194 insertions(+), 164 deletions(-) create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoDevice.kt create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoHome.kt create mode 100644 mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoInstalled.kt diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt index 545cb3fe11..9b43f0dd28 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Adb.kt @@ -1,5 +1,8 @@ package com.itsaky.androidide.mcp +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.TextContent + data class AdbResult( val exitCode: Int, val stdout: String, @@ -10,6 +13,24 @@ fun interface Adb { fun run(args: List): AdbResult } +/** Runs [args] under `adb shell`. */ +internal fun Adb.shell(vararg args: String): AdbResult = run(listOf("shell", *args)) + +/** + * Turns a failed adb call into an error result. + * + * Shared by every adb-backed tool so they answer the same way: a failed call means + * we do not know, which is never the same as a negative answer. Collapsing the two + * would make an unreachable device look like a missing app, or an empty project. + */ +internal fun adbFailure(result: AdbResult): CallToolResult { + val detail = result.stderr.trim().ifEmpty { result.stdout.trim() } + return CallToolResult( + content = listOf(TextContent("adb failed (exit ${result.exitCode}): $detail")), + isError = true, + ) +} + class SystemAdb( private val executable: String = "adb", ) : Adb { diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoDevice.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoDevice.kt new file mode 100644 index 0000000000..794b638309 --- /dev/null +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoDevice.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.mcp + +const val COGO_PACKAGE = "com.itsaky.androidide" + +/** The app's default SharedPreferences document, relative to its data directory. */ +internal const val COGO_PREFS_PATH = "shared_prefs/${COGO_PACKAGE}_preferences.xml" + +/** + * Wraps [command] so it runs as the app on a debuggable build. + * + * run-as enters the app's private data directory only. It cannot read shared + * storage, so anything under the projects directory must run as the plain shell + * user instead -- see [listProjectFilesCommand]. + */ +internal fun runAs(command: String) = "run-as $COGO_PACKAGE sh -c \"$command\"" + +// The prefs file does not exist until the app first writes one, and that must read +// as empty rather than as an adb failure - hence the `|| true`. +internal fun readCogoPreferencesCommand() = runAs("cat $COGO_PREFS_PATH 2>/dev/null || true") diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoHome.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoHome.kt new file mode 100644 index 0000000000..fb9c2e24c7 --- /dev/null +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoHome.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.mcp + +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import kotlinx.coroutines.delay +import java.util.Base64 + +const val MAIN_ACTIVITY = "$COGO_PACKAGE/.activities.MainActivity" + +private const val AUTO_OPEN_KEY = "idepref_general_autoOpenProjects" + +private const val AUTO_OPEN_ELEMENT = """""" + +private const val PREFS_HEADER = """""" + +/** + * Returns [existingXml] with auto-open-project disabled, creating the document if + * it is absent. + * + * tryOpenLastProject() falls back to the most recently modified project when no + * last project is recorded, so clearing ide_last_project would not be enough -- + * this boolean is the only thing that reliably prevents the jump to the editor. + * + * The edit is element-wise, not line-wise, and so is safe to repeat: an earlier + * version deleted whole lines on-device and destroyed the `` tag whenever a + * previous run had left it sharing a line with the boolean. + */ +fun withAutoOpenDisabled(existingXml: String): String { + val body = existingXml.trim() + if (body.isEmpty() || !body.contains("\n $AUTO_OPEN_ELEMENT\n\n" + } + + val updated = + body + .replace(Regex(""""""), "\n") + .replace(Regex("""\s*]*/>"""), "") + .replace("", " $AUTO_OPEN_ELEMENT\n") + + // 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 index 44977a576d..c8bfa7062c 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/CogoMcpServer.kt @@ -7,13 +7,10 @@ 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.coroutines.delay import kotlinx.serialization.json.JsonObject -import java.util.Base64 const val SERVER_NAME = "cogo-mcp" const val SERVER_VERSION = "0.1.0" -const val COGO_PACKAGE = "com.itsaky.androidide" private val SERVER_INSTRUCTIONS = """ @@ -27,54 +24,7 @@ private val SERVER_INSTRUCTIONS = not the same as an answer: it does not mean the app is absent. """.trimIndent() -const val MAIN_ACTIVITY = "$COGO_PACKAGE/.activities.MainActivity" - -private const val AUTO_OPEN_KEY = "idepref_general_autoOpenProjects" - -private const val PREFS_PATH = "shared_prefs/${COGO_PACKAGE}_preferences.xml" - -private const val AUTO_OPEN_ELEMENT = """""" - -private const val PREFS_HEADER = """""" - -/** - * Returns [existingXml] with auto-open-project disabled, creating the document if - * it is absent. - * - * tryOpenLastProject() falls back to the most recently modified project when no - * last project is recorded, so clearing ide_last_project would not be enough -- - * this boolean is the only thing that reliably prevents the jump to the editor. - * - * The edit is element-wise, not line-wise, and so is safe to repeat: an earlier - * version deleted whole lines on-device and destroyed the `` tag whenever a - * previous run had left it sharing a line with the boolean. - */ -fun withAutoOpenDisabled(existingXml: String): String { - val body = existingXml.trim() - if (body.isEmpty() || !body.contains("\n $AUTO_OPEN_ELEMENT\n\n" - } - - val updated = - body - .replace(Regex(""""""), "\n") - .replace(Regex("""\s*]*/>"""), "") - .replace("", " $AUTO_OPEN_ELEMENT\n") - - // 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" -} - -// The prefs file is absent until the app first writes one, and that must read as -// empty rather than as an adb failure - hence the `|| true`. -private fun readPreferencesCommand() = "run-as $COGO_PACKAGE sh -c \"cat $PREFS_PATH 2>/dev/null || true\"" - -// 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 "run-as $COGO_PACKAGE sh -c \"mkdir -p shared_prefs && echo $encoded | base64 -d > $PREFS_PATH\"" -} +private val NO_ARGUMENTS = ToolSchema(properties = JsonObject(emptyMap())) fun cogoMcpServer( adb: Adb = SystemAdb(), @@ -95,12 +45,12 @@ fun cogoMcpServer( ) // Handler is suspend ClientConnection.(CallToolRequest) -> CallToolResult: the - // ClientConnection is the receiver, not a parameter. ping uses neither. + // 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 = ToolSchema(properties = JsonObject(emptyMap())), + inputSchema = NO_ARGUMENTS, ) { _ -> CallToolResult(content = listOf(TextContent("pong"))) } @@ -111,7 +61,7 @@ fun cogoMcpServer( 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 = ToolSchema(properties = JsonObject(emptyMap())), + inputSchema = NO_ARGUMENTS, ) { _ -> isCogoInstalled(adb) } @@ -124,7 +74,7 @@ fun cogoMcpServer( "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 = ToolSchema(properties = JsonObject(emptyMap())), + inputSchema = NO_ARGUMENTS, ) { _ -> cogoHome(adb, homePollAttempts, homePollDelayMillis) } @@ -137,7 +87,7 @@ fun cogoMcpServer( "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 = ToolSchema(properties = JsonObject(emptyMap())), + inputSchema = NO_ARGUMENTS, ) { _ -> listProjects(adb) } @@ -150,7 +100,7 @@ fun cogoMcpServer( "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 = ToolSchema(properties = JsonObject(emptyMap())), + inputSchema = NO_ARGUMENTS, ) { _ -> listTemplates(adb) } @@ -164,101 +114,10 @@ fun cogoMcpServer( "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 = ToolSchema(properties = JsonObject(emptyMap())), + inputSchema = NO_ARGUMENTS, ) { _ -> listProjectFiles(adb) } return server } - -private 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.run(listOf("shell", "am", "force-stop", COGO_PACKAGE)).let { - if (it.exitCode != 0) return adbFailure(it) - } - val existingPrefs = adb.run(listOf("shell", readPreferencesCommand())) - if (existingPrefs.exitCode != 0) { - return adbFailure(existingPrefs) - } - adb.run(listOf("shell", writePreferencesCommand(withAutoOpenDisabled(existingPrefs.stdout)))).let { - if (it.exitCode != 0) return adbFailure(it) - } - adb.run(listOf("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.run(listOf("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("/") } - -// Shared by every adb-backed tool: a failed call means we do not know, which is -// never the same as a negative answer. -internal fun adbFailure(result: AdbResult): CallToolResult { - val detail = result.stderr.trim().ifEmpty { result.stdout.trim() } - return CallToolResult( - content = listOf(TextContent("adb failed (exit ${result.exitCode}): $detail")), - isError = true, - ) -} - -private fun isCogoInstalled(adb: Adb): CallToolResult { - val result = adb.run(listOf("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) - } - - // adb shell emits 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/ProjectFiles.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt index 2ddfa1e2b9..a6ea4efc84 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/ProjectFiles.kt @@ -10,8 +10,6 @@ private const val LAST_PROJECT_KEY = "ide_last_project" // unescaping. private const val NO_OPENED_PROJECT = "" -private const val PROJECT_PREFS_PATH = "shared_prefs/${COGO_PACKAGE}_preferences.xml" - private val EXCLUDED_DIRECTORIES = setOf("build", ".gradle", ".git") private const val EXCLUSION_NOTE = "build/, .gradle/ and .git/ are excluded." @@ -48,10 +46,6 @@ private fun unescapeXml(text: String) = // Last, so that an escaped entity such as &lt; does not become a tag. .replace("&", "&") -// The prefs file does not exist until the app writes one, and that must read as -// empty rather than as an adb failure - hence the `|| true`. -private fun readProjectPreferencesCommand() = "run-as $COGO_PACKAGE sh -c \"cat $PROJECT_PREFS_PATH 2>/dev/null || true\"" - // 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. @@ -105,7 +99,7 @@ internal fun describeProjectFiles( } fun listProjectFiles(adb: Adb): CallToolResult { - val prefs = adb.run(listOf("shell", readProjectPreferencesCommand())) + val prefs = adb.shell(readCogoPreferencesCommand()) if (prefs.exitCode != 0) { return adbFailure(prefs) } @@ -114,7 +108,7 @@ fun listProjectFiles(adb: Adb): CallToolResult { parseLastOpenedProject(prefs.stdout) ?: return CallToolResult(content = listOf(TextContent(NO_PROJECT_MESSAGE))) - val listing = adb.run(listOf("shell", listProjectFilesCommand(projectPath))) + val listing = adb.shell(listProjectFilesCommand(projectPath)) if (listing.exitCode != 0) { return adbFailure(listing) } diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt index 3d718ce7b4..eced5aec16 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Projects.kt @@ -77,7 +77,7 @@ internal fun describeProjects(listing: ProjectListing): String = } fun listProjects(adb: Adb): CallToolResult { - val result = adb.run(listOf("shell", listProjectsCommand())) + val result = adb.shell(listProjectsCommand()) if (result.exitCode != 0) { return adbFailure(result) } diff --git a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt index 7fda328bcb..1d0b5b9484 100644 --- a/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt +++ b/mcp/src/main/kotlin/com/itsaky/androidide/mcp/Templates.kt @@ -13,8 +13,7 @@ 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 const val LIST_ARCHIVES_COMMAND = - "run-as $COGO_PACKAGE sh -c \"ls $TEMPLATES_DIR 2>/dev/null || true\"" +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. " + @@ -63,10 +62,10 @@ private fun firstStringField( private fun readMemberCommand( archive: String, member: String, -) = "run-as $COGO_PACKAGE sh -c \"unzip -p $TEMPLATES_DIR/$archive $member\"" +) = runAs("unzip -p $TEMPLATES_DIR/$archive $member") fun listTemplates(adb: Adb): CallToolResult { - val listing = adb.run(listOf("shell", LIST_ARCHIVES_COMMAND)) + val listing = adb.shell(LIST_ARCHIVES_COMMAND) if (listing.exitCode != 0) { return adbFailure(listing) } @@ -84,12 +83,12 @@ fun listTemplates(adb: Adb): CallToolResult { val described = mutableListOf() for (archive in archives) { - val index = adb.run(listOf("shell", readMemberCommand(archive, INDEX_MEMBER))) + val index = adb.shell(readMemberCommand(archive, INDEX_MEMBER)) if (index.exitCode != 0) { return adbFailure(index) } for (path in parseTemplateIndex(index.stdout)) { - val template = adb.run(listOf("shell", readMemberCommand(archive, "$path/template/template.json"))) + val template = adb.shell(readMemberCommand(archive, "$path/template/template.json")) if (template.exitCode != 0) { return adbFailure(template) } From 824aa656e6284cc2da505e9d25292ef0d02b1a21 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 19:50:32 -0700 Subject: [PATCH 16/17] ADFA-5083: Record code-review defects in the backlog Three independent reviews (correctness, silent-failure, test-quality) against a real emulator, with mutation testing. Fourteen defects, three of them critical: a confirmed command injection through plugin-supplied template archives, a path where cogo_home overwrites every user preference and reports success, and list_project_files describing a project that is not open. They outrank every remaining tool on the list. The faults live in the shared command-building idiom, so each new tool would copy them, and nothing runs these tests in CI to catch a regression. Also adds a seventh ask of the CoGo app. Unlike the other six it is not a convenience: ide_last_project is written on open and never cleared, so there is no on-device signal for 'nothing is open' and no way for the tool to answer honestly. --- mcp/PRIORITIES.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++- mcp/TODO.txt | 28 +++++++++++++++++++---- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/mcp/PRIORITIES.md b/mcp/PRIORITIES.md index 472e8c5f20..e8aecb79fe 100644 --- a/mcp/PRIORITIES.md +++ b/mcp/PRIORITIES.md @@ -3,6 +3,18 @@ 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) @@ -133,6 +145,41 @@ on-device. Items 1, 2, 3 and 6 all depend on it. - 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 @@ -164,6 +211,15 @@ surface in a release build lets any installed app drive the IDE. `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. +problem. Ask 7 is the cheapest of the lot and removes a wrong answer rather than +an inconvenience. diff --git a/mcp/TODO.txt b/mcp/TODO.txt index ebfa1fb20d..cf36afd139 100644 --- a/mcp/TODO.txt +++ b/mcp/TODO.txt @@ -1,3 +1,25 @@ +Scored and prioritised in PRIORITIES.md. This file is the raw backlog. + +DONE +Ping (transport health check) +Is CoGo installed +Navigate to Home (cogo_home) +List available pre-existing projects +List available project templates +List files in current project + +DEFECTS FOUND IN CODE REVIEW - fix before adding tools +Quote shell interpolations in Templates.kt (command injection, confirmed executed on device) +Stop cogo_home overwriting all preferences when the prefs read is masked +Make SystemAdb return AdbResult instead of throwing when adb is missing +Add a timeout to SystemAdb (a wedged adb hangs the agent forever) +Distinguish "unreadable projects dir" from "no projects" +Report a stale recorded project path as an answer, not "adb failed" +list_project_files claims a project is open when none is (ide_last_project is "last ever opened") +Run mcp tests in CI - they run only by hand today +Assert tool descriptions, and pin host=127.0.0.1 + +TODO Navigate to new project activity Navigate to Open saved project activity Navigate to Clone a git project activity @@ -13,13 +35,11 @@ Navigate to About Cogo Navigate to Plugin Manager Navigate to Developer Options Navigate to Main Help -Move feedback button (takes 9 options: north, northeast, east, southeast, south, southwest, west, northwest, center) # like a tictactoe board -List available project templates -List available pre-existing projects +Move feedback button (9 positions, ratios 0.1/0.5/0.9; setup primitive, writes FabPrefs.xml) +Test the feedback button drag itself (item 16 bypasses DraggableTouchListener) Open a specific project Create a project from a template Open project left drawer Open project bottom drawer -List files in current project Save the current project Close the current project From 0b0ead4b920ee3da6353cddc337b3a4812bc3930 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 19:53:42 -0700 Subject: [PATCH 17/17] ADFA-5083: Add a status checkpoint for the MCP server work Captures where this stands so it can be picked up cold: six working tools, the fourteen review defects that now outrank the remaining backlog, the three open decisions, and the flox invocation that is easy to get wrong. Also records that emulator-5554 is not pristine - it carries a locally rebuilt debug APK, auto-open-project is permanently disabled by cogo_home, and only two of the five directories under CodeOnTheGoProjects are valid projects. Each of those would otherwise look like a bug to whoever resumes. --- mcp/STATUS.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 mcp/STATUS.md diff --git a/mcp/STATUS.md b/mcp/STATUS.md new file mode 100644 index 0000000000..7077ad8511 --- /dev/null +++ b/mcp/STATUS.md @@ -0,0 +1,99 @@ +# cogo-mcp status checkpoint + +**2026-08-11** · branch `ADFA-5083-mcp` · PR +[#1659](https://github.com/appdevforall/CodeOnTheGo/pull/1659) into `stage` · +ticket ADFA-5083 (In Progress) + +## Where this stands + +A host-side MCP server that drives Code On The Go over adb. Six tools, 69 tests, +all verified end-to-end against `emulator-5554` through the real MCP transport +rather than by calling handlers directly. + +**Working and merged to the branch:** + +| Tool | Verified result on device | +|---|---| +| `ping` | `pong` | +| `is_cogo_installed` | `... is installed.` | +| `cogo_home` | reaches `MainActivity`, twice in a row, prefs file intact | +| `list_projects` | 2 projects (both names contain spaces) | +| `list_templates` | 9 templates with descriptions | +| `list_project_files` | correct non-error "no project open" | + +Also on the branch: the debug build no longer registers two launcher activities +(LeakCanary's alias is disabled via the boolean resource it already gates on), so +a generic launch resolves to `SplashActivity` instead of the system +`ResolverActivity`. + +## Stop here before adding tools + +A three-lens code review (correctness, silent-failure, test-quality) found +**fourteen defects, three critical** -- a confirmed command injection, a path +where `cogo_home` overwrites every user preference and reports success, and +`list_project_files` describing a project that is not open. All reproduced on a +real device or by mutation testing. + +They are catalogued in [PRIORITIES.md](PRIORITIES.md#review-defects) with file +and line. They outrank the remaining backlog because the faults live in the +*shared* command-building idiom -- every new tool copies them -- and because +nothing runs these tests in CI. + +The unifying lesson, worth carrying forward: `exitCode` is checked at all twelve +adb call sites and is the wrong signal at four of them, because those commands +are deliberately written to keep the outer status at zero (`|| true`, +`|| exit 0`, `unzip -p`). Guard the inner failure, or emit an explicit marker -- +`listProjectsCommand` already does this with `NO_PROJECTS_DIR`. + +## Open questions for the next session + +1. **Fix the three criticals?** Recommended before any new tool. Highest-leverage + single change: make `CogoHomeTest`'s fake return a realistic preferences + document instead of `""` -- that alone kills the preference-wipe mutation and + opens up the merge-path and CRLF coverage. +2. **Split the LeakCanary commit into its own PR?** #1659 now spans `mcp/` and the + shipped debug APK, which is a different review audience. The commit + (`7e5382a9e`) is self-contained and cherry-picks cleanly. +3. **Wire `mcp/` into CI.** No workflow references it today. + +## Resuming + +Every Gradle command runs under flox, launched from the repo root -- the +environment's `on-activate` hook aborts if activated from inside `mcp/`: + +```bash +flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew test' +flox activate -d flox/local -- bash -c 'cd mcp && ./gradlew run' +``` + +Formatting runs from the **repo root**, not from `mcp/`: root Spotless does reach +into top-level standalone directories, so `mcp/` uses tabs. + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +``` + +## Device state left behind + +`emulator-5554` is not pristine. Anyone resuming should know: + +- It runs a **locally rebuilt debug APK** carrying the launcher fix. It is not the + same binary as CI produces. +- `cogo_home` has **permanently disabled auto-open-project** + (`idepref_general_autoOpenProjects=false`). That is the tool working as + designed, not a leftover, but the app will no longer reopen the last project. +- Onboarding is complete and `core.cgt` is unpacked, so `list_templates` returns + data. A fresh device returns the "not installed yet" answer instead. +- Of the five directories under `/storage/emulated/0/CodeOnTheGoProjects`, only + two are valid CoGo projects; the rest are Flutter projects and a template + archive. That is correct behaviour, not a bug. + +## Reading order + +- [PRIORITIES.md](PRIORITIES.md) -- the rubric, all 25 backlog items scored, the + reachability facts measured on-device, the review defects, and seven asks of the + CoGo app that would make this work dramatically easier. +- [README.md](README.md) -- how to run, test and register the server. +- [TODO.txt](TODO.txt) -- the raw backlog. +- `docs/superpowers/specs/2026-08-10-mcp-server-design.md` -- the design and why + host-side, loopback, no TLS.