Give Claude/Codex/Cursor Code instant live knowledge of your running Android app — no source files read, no credentials exposed, zero production overhead.
Claude Code connects to an MCP server embedded in your debug APK. It discovers your app's classes and public functions, calls them live on the emulator, and verifies full feature flows using intelligent mock responses. When you're ready, flip individual groups to live mode for real API testing.
Your project AndroClaudio
──────────────── ──────────────────────────────────────────
groups.json ──KSP──▶ Generated_*Registry.kt (compile time)
Application.onCreate ──────▶ MCP server starts on port 5173 (runtime)
adb forward tcp:5173 ──────▶ Claude Code calls /tools/list + /tools/call
Three components, zero production impact:
| Component | What it does | When it runs |
|---|---|---|
androplaudio-setup CLI |
Scans project, writes groups.json |
Once per project setup |
androplaudio-ksp |
Reads groups.json, generates type-safe call registries |
Compile time (KSP) |
androplaudio-core AAR |
Embeds MCP server in debug APK | Debug runtime only |
debugImplementation means the AAR is completely absent from release builds — no code, no size, no overhead.
- Android
minSdk 21+ - Kotlin
1.9+ - KSP plugin
1.9.23-1.0.20or newer - Node.js
18+(setup CLI, one time only)
# 1. Scan your project
npx androplaudio-setup --project-dir . --output androplaudio-groups.json
# 2. Add files, configure Gradle, add one line to Application (see below)
# 3. Build
./gradlew assembleDebug
# 4. Forward port
adb forward tcp:5173 tcp:5173
# 5. Verify
curl http://localhost:5173/tools/list | jq .Download from releases/1.0.0/ and place in your app module:
your-app/
└── app/
└── libs/
├── androplaudio-core-1.0.0.aar ← runtime MCP server (debug only)
└── androplaudio-ksp-1.0.0.jar ← KSP code generator (build time only)
app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp") version "1.9.23-1.0.20" // add if not present
}
ksp {
// path to the groups.json you generated in the previous step
arg("androplaudio.groupsJson", "${rootDir}/androplaudio-groups.json")
}
dependencies {
// KSP processor — runs at build time only, generates call registries
ksp(files("libs/androplaudio-ksp-1.0.0.jar"))
// Runtime MCP server — debug builds only, completely absent from release
debugImplementation(files("libs/androplaudio-core-1.0.0.aar"))
}If you already have the KSP plugin, just add the two dependency lines and the
ksp { }block.
npx androplaudio-setup --project-dir /path/to/your/app --output androplaudio-groups.jsonThis scans your project and writes androplaudio-groups.json. It detects your DI framework automatically (Koin, Hilt, Dagger, or manual) and finds every registered class.
Example output:
{
"version": "1",
"platform": "android",
"framework": "koin",
"groups": [
{ "id": "payment.repository", "layer": "shared", "class": "com.myapp.data.PaymentRepository" },
{ "id": "order.use.case", "layer": "shared", "class": "com.myapp.domain.OrderUseCase" },
{ "id": "cart.manager", "layer": "shared", "class": "com.myapp.domain.CartManager" }
]
}Re-run whenever you add a new class. The file is plain JSON — you can also edit it manually.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// your existing DI setup — completely unchanged
startKoin {
androidContext(this@MyApp)
modules(appModule)
}
// add this one line
if (BuildConfig.DEBUG) AndroClaudio.initialize(this)
}
}That's it. No other changes to your app.
What initialize() does:
- Loads
GeneratedGroupRegistry(generated by KSP at build time) via reflection - Starts a Ktor CIO HTTP server on port 5173 in a background thread
- Wires up Koin auto-resolution so any class in your DI graph is callable
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkConfirm it started — look for this in logcat:
I/AndroClaudio: MCP server started on port 5173 — 4 groups registered
Run this once per emulator/device session:
adb forward tcp:5173 tcp:5173Verify:
curl http://localhost:5173/tools/list | jq .You should see all your registered groups.
AndroClaudio auto-resolves Koin singletons using GlobalContext.get() reflection. Your initialize(this) call is all you need.
// SampleApp.kt — nothing extra needed
if (BuildConfig.DEBUG) AndroClaudio.initialize(this)Hilt and Dagger don't expose a global service locator, so register the instances you want Claude Code to call after your DI graph is built:
@HiltAndroidApp
class MyApp : Application() {
@Inject lateinit var paymentRepository: PaymentRepository
@Inject lateinit var orderUseCase: OrderUseCase
override fun onCreate() {
super.onCreate()
// Hilt injects fields above before this point
if (BuildConfig.DEBUG) {
AndroClaudio.initialize(this)
AndroClaudio.registerInstance(
"com.myapp.data.PaymentRepository", paymentRepository
)
AndroClaudio.registerInstance(
"com.myapp.domain.OrderUseCase", orderUseCase
)
}
}
}if (BuildConfig.DEBUG) {
AndroClaudio.initialize(this)
AndroClaudio.registerInstance(
"com.myapp.data.PaymentRepository",
ServiceLocator.paymentRepository
)
}registerInstance() always wins over Koin auto-resolution — use it to override which instance is used for any class.
When Claude Code calls a function via MCP, it hits the same live instance your UI is using. If that class exposes a StateFlow, the UI updates automatically with no extra code.
Pattern — expose StateFlow from your domain class:
class CartManager {
private val items = mutableMapOf<Int, Int>()
// Expose state reactively
private val _cartFlow = MutableStateFlow<Map<Int, Int>>(emptyMap())
val cartFlow: StateFlow<Map<Int, Int>> = _cartFlow.asStateFlow()
fun addItem(productId: Int, quantity: Int = 1) {
items[productId] = (items[productId] ?: 0) + quantity
_cartFlow.value = items.toMap() // ← emit on every change
}
}ViewModel collects the flow:
class CartViewModel : ViewModel(), KoinComponent {
private val cartManager: CartManager by inject()
private val _cartCount = MutableStateFlow(0)
val cartCount: StateFlow<Int> = _cartCount
init {
viewModelScope.launch {
cartManager.cartFlow.collect { items ->
_cartCount.value = items.values.sum()
}
}
}
}Now when Claude Code calls cart.manager.addItem via MCP:
MCP → CartManager.addItem() → _cartFlow emits → ViewModel collects → screen updates
No ViewModels need to be MCP-callable. The reactive chain handles screen updates automatically.
Mock mode is the default. Every function call returns an intelligent, structurally correct response — no real API, no credentials needed.
Mock responses are generated from the function name and parameter names:
| MCP call | Mock response |
|---|---|
processPayment(amount: 1000, currency: "INR") |
{ success: true, txnId: "MOCK_TXN_001", amount: 1000, _mock: true } |
getBalance(accountId: "ACC_001") |
{ accountId: "ACC_001", balance: 50000.0, _mock: true } |
isUserLoggedIn() |
{ result: true, _mock: true } |
searchProducts(query: "book") |
[{ name: "book_result", _mock: true }] |
All mock responses include "_mock": true so they are always distinguishable.
Add androplaudio.config.json to src/debug/assets/:
app/src/debug/assets/androplaudio.config.json
{
"defaultMode": "mock",
"groups": {
"payment.repository": "live",
"cart.manager": "live",
"order.use.case": "mock"
}
}- Groups not listed use
defaultMode - Changing this file takes effect on the next app launch (no rebuild needed)
- Add to
.gitignoreif it contains staging credentials
In live mode, AndroClaudio resolves the real DI instance and calls the actual function. The result is the same object your app would return.
Once the app is running and port is forwarded, these endpoints are available:
Health check.
{ "server": "androplaudio", "version": "1.0", "port": 5173 }Lists all registered groups.
[
{ "id": "cart.manager", "layer": "shared", "className": "com.myapp.CartManager", "toolCount": 8 },
{ "id": "payment.repository", "layer": "shared", "className": "com.myapp.PaymentRepository", "toolCount": 6 }
]Lists all public functions in a group with parameter types.
{
"id": "cart.manager",
"tools": [
{ "name": "addItem", "params": [{ "name": "productId", "type": "Int" }, { "name": "quantity", "type": "Int" }], "returnType": "Unit" },
{ "name": "getItemCount", "params": [], "returnType": "Int" }
]
}Calls a function and returns the result.
Request:
{
"group": "cart.manager",
"fn": "addItem",
"args": { "productId": 1, "quantity": 2 }
}Response (live):
{ "result": null }
nullresult is correct forUnit-returning functions — it means the call succeeded.
Response (mock):
{ "result": { "_mock": true }, "mock": true }Response (error):
{ "error": "Group not found: unknown.group" }// shared/build.gradle.kts
kotlin {
sourceSets {
androidMain.dependencies {
debugImplementation(files("libs/androplaudio-core-1.0.0.aar"))
}
}
}
dependencies {
add("kspAndroid", files("libs/androplaudio-ksp-1.0.0.jar"))
}// androidMain — Application entry point
if (BuildConfig.DEBUG) AndroClaudio.initialize(application)The setup CLI detects KMM/CMP projects and marks shared classes as "layer": "shared" and Android-specific classes as "layer": "android".
Drag AndroClaudio.xcframework from releases/1.0.0/ into Xcode.
In Build Phases → Link Binary With Libraries, add it under the Debug configuration only.
import AndroClaudio
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
#if DEBUG
AndroClaudio.shared.initialize(port: 5173) { registry in
GeneratedGroupRegistry.shared.registerAll(registry: registry)
}
#endif
return true
}
}Add androplaudio.config.json to your Xcode bundle resources (Debug configuration only).
iproxy 5173 5173 &
curl http://localhost:5173/tools/list | jq .
iproxyis part oflibimobiledevice:brew install libimobiledevice
# 1. List all groups
curl -s http://localhost:5173/tools/list | jq .
# 2. Inspect a specific group
curl -s "http://localhost:5173/tools/list?group=cart.manager" | jq .
# 3. Call a function (mock mode)
curl -s -X POST http://localhost:5173/tools/call \
-H "Content-Type: application/json" \
-d '{"group":"cart.manager","fn":"addItem","args":{"productId":1,"quantity":2}}' | jq .
# 4. Verify live state
curl -s -X POST http://localhost:5173/tools/call \
-H "Content-Type: application/json" \
-d '{"group":"cart.manager","fn":"getItemCount","args":{}}' | jq .result{
"version": "1",
"platform": "android",
"framework": "koin",
"groups": [
{
"id": "payment.repository",
"layer": "shared",
"class": "com.myapp.data.PaymentRepository"
}
]
}| Field | Values | Description |
|---|---|---|
id |
string (dot/hyphen separated) | Unique identifier used in MCP calls |
layer |
shared, android, ui |
Architectural layer — informational |
class |
fully qualified class name | The class KSP will generate a registry for |
The CLI generates this automatically. Edit it manually to add, remove, or rename groups.
KSP automatically filters out these from the generated tool list:
- Android lifecycle methods:
onCreate,onDestroy,onStart,onStop,onPause,onResume,onBind,onUnbind,onStartCommand,onRebind,onReceive - Worker methods:
doWork,onStopped,getForegroundInfo - Kotlin object methods:
equals,hashCode,toString,copy - ContentProvider methods:
query,insert,delete,update,getType,openFile - Kotlin synthetic: methods prefixed
component,access$ - Constructors:
<init>,<clinit>
Only public business logic functions appear as callable tools.
AndroClaudio uses a two-level discovery strategy:
| Request | Tokens | When |
|---|---|---|
GET /tools/list |
~300 | First call — always |
GET /tools/list?group=<id> |
~800–1500 per group | Only groups Claude needs |
POST /tools/call |
~50 per call | Each function execution |
A typical session (5 groups, 10 calls): ~6,000 tokens
Without AndroClaudio (reading source files): 20,000–50,000 tokens just to understand structure.
GeneratedGroupRegistry not found in logcat
KSP hasn't run yet. Run ./gradlew assembleDebug. The generated files appear in:
app/build/generated/ksp/debug/kotlin/com/androplaudio/generated/
Port 5173 not reachable after emulator restart
Run adb forward tcp:5173 tcp:5173 — this must be re-run after each restart.
addItem returns null — is something wrong?
No. Functions that return Unit (void) correctly produce null in JSON. Check the actual state by calling a getter:
curl ... -d '{"group":"cart.manager","fn":"getItemCount","args":{}}'
Group shows mock responses even in live mode
Check androplaudio.config.json is in src/debug/assets/ (not src/main/assets/) and the group id matches exactly (case-sensitive).
Koin instance not resolving — getting a fresh object each call
AndroClaudio auto-resolves Koin single definitions. If your class is a factory (new instance each time) or not in Koin at all, use AndroClaudio.registerInstance() after your DI setup to pin the exact instance.
Screen doesn't update when MCP calls a function
Expose a StateFlow from your domain class and collect it in the ViewModel (see Reactive UI Updates section). Changes via MCP then propagate to the screen automatically.
null cannot be cast to non-null type kotlin.Int error
You're calling a function with a parameter that has a default value but not passing it explicitly. Always pass all parameters when calling via MCP:
"args": {"productId": 1, "quantity": 1} not "args": {"productId": 1}
Everything in the library is internal except these:
// Android (androidMain)
object AndroClaudio {
fun initialize(app: Application, port: Int = 5173)
fun registerInstance(fqcn: String, instance: Any)
fun stop()
}
// iOS / CMP (iosMain)
object AndroClaudio {
fun initialize(port: Int = 5173, registerGroups: (GroupRegistry) -> Unit = {})
fun registerInstance(fqcn: String, instance: Any)
fun stop()
}
// Used in KSP-generated code
data class ToolMetadata(val name: String, val params: List<ParamMetadata>, val returnType: String)
data class ParamMetadata(val name: String, val type: String)
class GroupRegistryandroplaudio/
├── androplaudio-agent-skill/ Node.js CLI — npx androplaudio-setup
│ └── src/
│ ├── cli.js entry point
│ ├── detector.js detects platform + DI framework
│ ├── scanner.js extracts class names from source + manifests
│ └── writer.js writes groups.json
├── androplaudio-ksp/ KSP annotation processor
│ └── src/main/kotlin/
│ ├── GroupProcessor.kt reads groups.json → generates registries
│ ├── LifecycleFilter.kt excludes lifecycle/boilerplate methods
│ └── GroupModels.kt data classes for groups.json parsing
└── androplaudio-core/ KMP runtime library
└── src/
├── commonMain/ shared logic — no platform imports
│ ├── ToolMetadata.kt
│ ├── GroupRegistry.kt
│ ├── MockResponseGenerator.kt
│ ├── MCPServer.kt (expect)
│ └── ModeResolver.kt (expect)
├── androidMain/ Android implementation
│ ├── MCPServer.android.kt Ktor CIO server
│ ├── ModeResolver.android.kt reads assets
│ ├── DIResolver.kt Koin auto + manual fallback
│ └── AndroClaudio.kt public entry point
└── iosMain/ iOS implementation
├── MCPServer.ios.kt Ktor CIO (Native)
├── ModeResolver.ios.kt reads NSBundle
├── IOSResolver.kt manual-only resolver
└── AndroClaudio.kt public entry point
