replicant-mcp 1.0.0 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -22
- package/dist/adapters/adb.d.ts +1 -0
- package/dist/adapters/adb.js +7 -1
- package/dist/adapters/emulator.js +11 -11
- package/dist/adapters/ui-automator.d.ts +26 -1
- package/dist/adapters/ui-automator.js +72 -8
- package/dist/cli/gradle.js +3 -3
- package/dist/cli.js +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +4 -2
- package/dist/services/device-state.d.ts +2 -0
- package/dist/services/device-state.js +18 -0
- package/dist/services/environment.d.ts +18 -0
- package/dist/services/environment.js +130 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/ocr.d.ts +4 -0
- package/dist/services/ocr.js +59 -0
- package/dist/services/process-runner.d.ts +6 -0
- package/dist/services/process-runner.js +26 -0
- package/dist/tools/adb-app.js +3 -2
- package/dist/tools/adb-device.d.ts +1 -0
- package/dist/tools/adb-device.js +47 -8
- package/dist/tools/adb-logcat.js +3 -2
- package/dist/tools/adb-shell.js +3 -2
- package/dist/tools/gradle-get-details.d.ts +1 -1
- package/dist/tools/ui.d.ts +10 -0
- package/dist/tools/ui.js +71 -8
- package/dist/types/errors.d.ts +25 -2
- package/dist/types/errors.js +23 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/ocr.d.ts +21 -0
- package/dist/types/ocr.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,6 +10,12 @@ replicant-mcp is a [Model Context Protocol](https://modelcontextprotocol.io/) se
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## Demo
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
13
19
|
## Why replicant-mcp?
|
|
14
20
|
|
|
15
21
|
Android development involves juggling a lot: Gradle builds, emulator management, ADB commands, logcat filtering, UI testing. Each has its own CLI, flags, and quirks.
|
|
@@ -25,6 +31,44 @@ replicant-mcp wraps all of this into a clean interface that AI can understand an
|
|
|
25
31
|
|
|
26
32
|
---
|
|
27
33
|
|
|
34
|
+
## Current Features
|
|
35
|
+
|
|
36
|
+
| Category | Capabilities |
|
|
37
|
+
|----------|-------------|
|
|
38
|
+
| **Build & Test** | Build APKs/bundles, run unit and instrumented tests, list modules/variants/tasks, fetch detailed build logs |
|
|
39
|
+
| **Emulator** | Create, start, stop, wipe emulators; save/load/delete snapshots |
|
|
40
|
+
| **Device Control** | List connected devices, select active device, query device properties |
|
|
41
|
+
| **App Management** | Install, uninstall, launch, stop apps; clear app data; list installed packages |
|
|
42
|
+
| **Log Analysis** | Filter logcat by package, tag, level, time; configurable line limits |
|
|
43
|
+
| **UI Automation** | Accessibility-tree based element finding, tap, text input, screenshots |
|
|
44
|
+
| **Utilities** | Response caching with progressive disclosure, on-demand documentation |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Future Roadmap
|
|
49
|
+
|
|
50
|
+
| Feature | Item | Status |
|
|
51
|
+
|---------|------|--------|
|
|
52
|
+
| **Visual Fallback** | Screenshot + metadata on accessibility failure | Planned |
|
|
53
|
+
| | `visual-snapshot` operation for explicit visual mode | Planned |
|
|
54
|
+
| | YAML config via `REPLICANT_CONFIG` | Planned |
|
|
55
|
+
| | OCR fallback for text search (tesseract.js) | Planned |
|
|
56
|
+
| | Icon recognition (template matching for common UI icons) | Planned |
|
|
57
|
+
| | Semantic image search (LLM-assisted visual understanding) | Future |
|
|
58
|
+
| **Custom Build Commands** | Skill override for project-specific builds | Planned |
|
|
59
|
+
| | Auto-detect gradlew vs gradle | Planned |
|
|
60
|
+
| | Configurable default variant | Planned |
|
|
61
|
+
| | Extend skill override to test/lint operations | Future |
|
|
62
|
+
| **Video Capture** | Start/stop recording | Planned |
|
|
63
|
+
| | Duration-based capture | Planned |
|
|
64
|
+
| | Configurable output directory and quality | Planned |
|
|
65
|
+
| | WebM/GIF conversion (ffmpeg) | Future |
|
|
66
|
+
| **Developer Experience** | Simplified tool authoring with `defineTool()` helper | Future |
|
|
67
|
+
| | Auto-generate JSON schema from Zod via `zod-to-json-schema` | Future |
|
|
68
|
+
| | Convention-based tool auto-discovery (no manual wiring) | Future |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
28
72
|
## Quick Start
|
|
29
73
|
|
|
30
74
|
### Prerequisites
|
|
@@ -43,18 +87,17 @@ emulator -version # Should show Android emulator version
|
|
|
43
87
|
|
|
44
88
|
### Installation
|
|
45
89
|
|
|
90
|
+
**Option 1: npm (recommended)**
|
|
91
|
+
```bash
|
|
92
|
+
npm install -g replicant-mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Option 2: From source**
|
|
46
96
|
```bash
|
|
47
|
-
# Clone the repo
|
|
48
97
|
git clone https://github.com/thecombatwombat/replicant-mcp.git
|
|
49
98
|
cd replicant-mcp
|
|
50
|
-
|
|
51
|
-
# Install dependencies
|
|
52
99
|
npm install
|
|
53
|
-
|
|
54
|
-
# Build
|
|
55
100
|
npm run build
|
|
56
|
-
|
|
57
|
-
# Verify everything works
|
|
58
101
|
npm test
|
|
59
102
|
```
|
|
60
103
|
|
|
@@ -75,31 +118,51 @@ Add this to your Claude Desktop config (`~/Library/Application Support/Claude/cl
|
|
|
75
118
|
|
|
76
119
|
Restart Claude Desktop. You should see "replicant" in the MCP servers list.
|
|
77
120
|
|
|
78
|
-
###
|
|
121
|
+
### Connect to Claude Code
|
|
79
122
|
|
|
80
|
-
|
|
123
|
+
Add the MCP server with environment variables for Android SDK:
|
|
81
124
|
|
|
82
|
-
**Option 1: Via Plugin Marketplace (Recommended)**
|
|
83
125
|
```bash
|
|
84
|
-
|
|
85
|
-
/
|
|
126
|
+
claude mcp add replicant \
|
|
127
|
+
-e ANDROID_HOME=$HOME/Library/Android/sdk \
|
|
128
|
+
-e PATH="$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH" \
|
|
129
|
+
-- node $(npm root -g)/replicant-mcp/dist/index.js
|
|
86
130
|
```
|
|
87
131
|
|
|
88
|
-
**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
> **Note:** Adjust `ANDROID_HOME` if your Android SDK is in a different location. On Linux, it's typically `$HOME/Android/Sdk`.
|
|
133
|
+
|
|
134
|
+
Restart Claude Code to load the MCP server.
|
|
135
|
+
|
|
136
|
+
### Reducing Permission Prompts (Optional)
|
|
137
|
+
|
|
138
|
+
By default, Claude Code asks for permission on each tool call. To auto-approve replicant-mcp tools, add this to your `.claude/settings.json`:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"permissions": {
|
|
143
|
+
"allow": [
|
|
144
|
+
"mcp__replicant__*"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
92
148
|
```
|
|
93
149
|
|
|
94
|
-
|
|
150
|
+
This is especially useful for agentic workflows where human intervention is limited.
|
|
95
151
|
|
|
96
|
-
|
|
152
|
+
### PR Automation (Optional)
|
|
97
153
|
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
-
|
|
154
|
+
This project includes a Claude Code skill for automated PR handling. When invoked, it:
|
|
155
|
+
- Creates a branch and PR from your current changes
|
|
156
|
+
- Polls for Greptile and human reviews every 2 minutes (max 5 cycles)
|
|
157
|
+
- Automatically addresses Greptile feedback
|
|
158
|
+
- Merges when a human approves
|
|
159
|
+
|
|
160
|
+
To use:
|
|
161
|
+
```
|
|
162
|
+
/pr-with-review --branch feature/my-feature --title "My PR" --body "Description" --commit-message "feat: add feature"
|
|
163
|
+
```
|
|
101
164
|
|
|
102
|
-
|
|
165
|
+
Or let Claude invoke it automatically when creating PRs.
|
|
103
166
|
|
|
104
167
|
---
|
|
105
168
|
|
package/dist/adapters/adb.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare class AdbAdapter {
|
|
|
11
11
|
stop(deviceId: string, packageName: string): Promise<void>;
|
|
12
12
|
clearData(deviceId: string, packageName: string): Promise<void>;
|
|
13
13
|
shell(deviceId: string, command: string, timeoutMs?: number): Promise<RunResult>;
|
|
14
|
+
pull(deviceId: string, remotePath: string, localPath: string): Promise<void>;
|
|
14
15
|
logcat(deviceId: string, options: {
|
|
15
16
|
lines?: number;
|
|
16
17
|
filter?: string;
|
package/dist/adapters/adb.js
CHANGED
|
@@ -45,6 +45,12 @@ export class AdbAdapter {
|
|
|
45
45
|
async shell(deviceId, command, timeoutMs) {
|
|
46
46
|
return this.adb(["-s", deviceId, "shell", command], timeoutMs);
|
|
47
47
|
}
|
|
48
|
+
async pull(deviceId, remotePath, localPath) {
|
|
49
|
+
const result = await this.adb(["-s", deviceId, "pull", remotePath, localPath]);
|
|
50
|
+
if (result.exitCode !== 0) {
|
|
51
|
+
throw new ReplicantError(ErrorCode.PULL_FAILED, `Failed to pull ${remotePath} to ${localPath}`, result.stderr || "Check device connection and file paths");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
48
54
|
async logcat(deviceId, options) {
|
|
49
55
|
const args = ["-s", deviceId, "logcat", "-d"];
|
|
50
56
|
if (options.lines) {
|
|
@@ -70,6 +76,6 @@ export class AdbAdapter {
|
|
|
70
76
|
return props;
|
|
71
77
|
}
|
|
72
78
|
async adb(args, timeoutMs) {
|
|
73
|
-
return this.runner.
|
|
79
|
+
return this.runner.runAdb(args, { timeoutMs });
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -8,8 +8,8 @@ export class EmulatorAdapter {
|
|
|
8
8
|
}
|
|
9
9
|
async list() {
|
|
10
10
|
const [avdResult, runningResult] = await Promise.all([
|
|
11
|
-
this.runner.
|
|
12
|
-
this.runner.
|
|
11
|
+
this.runner.runAvdManager(["list", "avd"]),
|
|
12
|
+
this.runner.runEmulator(["-list-avds"]),
|
|
13
13
|
]);
|
|
14
14
|
return {
|
|
15
15
|
available: parseAvdList(avdResult.stdout),
|
|
@@ -17,7 +17,7 @@ export class EmulatorAdapter {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
async create(name, device, systemImage) {
|
|
20
|
-
const result = await this.runner.
|
|
20
|
+
const result = await this.runner.runAvdManager([
|
|
21
21
|
"create", "avd",
|
|
22
22
|
"-n", name,
|
|
23
23
|
"-k", systemImage,
|
|
@@ -31,7 +31,7 @@ export class EmulatorAdapter {
|
|
|
31
31
|
async start(avdName) {
|
|
32
32
|
// Start emulator in background - don't wait for it
|
|
33
33
|
// Returns immediately, emulator boots in background
|
|
34
|
-
this.runner.
|
|
34
|
+
this.runner.runEmulator([
|
|
35
35
|
"-avd", avdName,
|
|
36
36
|
"-no-snapshot-load",
|
|
37
37
|
"-no-boot-anim",
|
|
@@ -41,7 +41,7 @@ export class EmulatorAdapter {
|
|
|
41
41
|
// Give it a moment to register
|
|
42
42
|
await new Promise((r) => setTimeout(r, 2000));
|
|
43
43
|
// Find the new emulator ID
|
|
44
|
-
const result = await this.runner.
|
|
44
|
+
const result = await this.runner.runAdb(["devices"]);
|
|
45
45
|
const match = result.stdout.match(/emulator-\d+/);
|
|
46
46
|
if (!match) {
|
|
47
47
|
throw new ReplicantError(ErrorCode.EMULATOR_START_FAILED, `Emulator ${avdName} failed to start`, "Check the AVD name and try again");
|
|
@@ -49,24 +49,24 @@ export class EmulatorAdapter {
|
|
|
49
49
|
return match[0];
|
|
50
50
|
}
|
|
51
51
|
async kill(emulatorId) {
|
|
52
|
-
await this.runner.
|
|
52
|
+
await this.runner.runAdb(["-s", emulatorId, "emu", "kill"]);
|
|
53
53
|
}
|
|
54
54
|
async wipe(avdName) {
|
|
55
|
-
await this.runner.
|
|
55
|
+
await this.runner.runEmulator(["-avd", avdName, "-wipe-data", "-no-window"], { timeoutMs: 5000 }).catch(() => {
|
|
56
56
|
// Expected behavior
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
async snapshotSave(emulatorId, name) {
|
|
60
|
-
await this.runner.
|
|
60
|
+
await this.runner.runAdb(["-s", emulatorId, "emu", "avd", "snapshot", "save", name]);
|
|
61
61
|
}
|
|
62
62
|
async snapshotLoad(emulatorId, name) {
|
|
63
|
-
await this.runner.
|
|
63
|
+
await this.runner.runAdb(["-s", emulatorId, "emu", "avd", "snapshot", "load", name]);
|
|
64
64
|
}
|
|
65
65
|
async snapshotList(emulatorId) {
|
|
66
|
-
const result = await this.runner.
|
|
66
|
+
const result = await this.runner.runAdb(["-s", emulatorId, "emu", "avd", "snapshot", "list"]);
|
|
67
67
|
return parseSnapshotList(result.stdout);
|
|
68
68
|
}
|
|
69
69
|
async snapshotDelete(emulatorId, name) {
|
|
70
|
-
await this.runner.
|
|
70
|
+
await this.runner.runAdb(["-s", emulatorId, "emu", "avd", "snapshot", "delete", name]);
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { AdbAdapter } from "./adb.js";
|
|
2
2
|
import { AccessibilityNode } from "../parsers/ui-dump.js";
|
|
3
|
+
import { OcrElement } from "../types/index.js";
|
|
4
|
+
export interface ScreenshotOptions {
|
|
5
|
+
localPath?: string;
|
|
6
|
+
inline?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface ScreenshotResult {
|
|
9
|
+
mode: "file" | "inline";
|
|
10
|
+
path?: string;
|
|
11
|
+
base64?: string;
|
|
12
|
+
sizeBytes?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface FindWithOcrResult {
|
|
15
|
+
elements: (AccessibilityNode | OcrElement)[];
|
|
16
|
+
source: "accessibility" | "ocr";
|
|
17
|
+
fallbackReason?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface FindOptions {
|
|
20
|
+
debug?: boolean;
|
|
21
|
+
}
|
|
3
22
|
export declare class UiAutomatorAdapter {
|
|
4
23
|
private adb;
|
|
5
24
|
constructor(adb?: AdbAdapter);
|
|
@@ -13,11 +32,17 @@ export declare class UiAutomatorAdapter {
|
|
|
13
32
|
tap(deviceId: string, x: number, y: number): Promise<void>;
|
|
14
33
|
tapElement(deviceId: string, element: AccessibilityNode): Promise<void>;
|
|
15
34
|
input(deviceId: string, text: string): Promise<void>;
|
|
16
|
-
screenshot(deviceId: string,
|
|
35
|
+
screenshot(deviceId: string, options?: ScreenshotOptions): Promise<ScreenshotResult>;
|
|
17
36
|
accessibilityCheck(deviceId: string): Promise<{
|
|
18
37
|
hasAccessibleElements: boolean;
|
|
19
38
|
clickableCount: number;
|
|
20
39
|
textCount: number;
|
|
21
40
|
totalElements: number;
|
|
22
41
|
}>;
|
|
42
|
+
findWithOcrFallback(deviceId: string, selector: {
|
|
43
|
+
resourceId?: string;
|
|
44
|
+
text?: string;
|
|
45
|
+
textContains?: string;
|
|
46
|
+
className?: string;
|
|
47
|
+
}, options?: FindOptions): Promise<FindWithOcrResult>;
|
|
23
48
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AdbAdapter } from "./adb.js";
|
|
2
2
|
import { parseUiDump, findElements, flattenTree } from "../parsers/ui-dump.js";
|
|
3
|
+
import { ReplicantError, ErrorCode } from "../types/index.js";
|
|
4
|
+
import { extractText, searchText } from "../services/ocr.js";
|
|
3
5
|
export class UiAutomatorAdapter {
|
|
4
6
|
adb;
|
|
5
7
|
constructor(adb = new AdbAdapter()) {
|
|
@@ -29,14 +31,35 @@ export class UiAutomatorAdapter {
|
|
|
29
31
|
const escaped = text.replace(/(['"\\$`])/g, "\\$1").replace(/ /g, "%s");
|
|
30
32
|
await this.adb.shell(deviceId, `input text "${escaped}"`);
|
|
31
33
|
}
|
|
32
|
-
async screenshot(deviceId,
|
|
33
|
-
const remotePath = "/sdcard/screenshot.png";
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
async screenshot(deviceId, options = {}) {
|
|
35
|
+
const remotePath = "/sdcard/replicant-screenshot.png";
|
|
36
|
+
// Capture screenshot on device
|
|
37
|
+
const captureResult = await this.adb.shell(deviceId, `screencap -p ${remotePath}`);
|
|
38
|
+
if (captureResult.exitCode !== 0) {
|
|
39
|
+
throw new ReplicantError(ErrorCode.SCREENSHOT_FAILED, "Failed to capture screenshot", captureResult.stderr || "Ensure device screen is on and unlocked");
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
if (options.inline) {
|
|
43
|
+
// Inline mode: return base64
|
|
44
|
+
const base64Result = await this.adb.shell(deviceId, `base64 ${remotePath}`);
|
|
45
|
+
const sizeResult = await this.adb.shell(deviceId, `stat -c%s ${remotePath}`);
|
|
46
|
+
return {
|
|
47
|
+
mode: "inline",
|
|
48
|
+
base64: base64Result.stdout.trim(),
|
|
49
|
+
sizeBytes: parseInt(sizeResult.stdout.trim(), 10),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// File mode (default): pull to local
|
|
54
|
+
const localPath = options.localPath || `/tmp/replicant-screenshot-${Date.now()}.png`;
|
|
55
|
+
await this.adb.pull(deviceId, remotePath, localPath);
|
|
56
|
+
return { mode: "file", path: localPath };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
// Always clean up remote file
|
|
61
|
+
await this.adb.shell(deviceId, `rm -f ${remotePath}`);
|
|
62
|
+
}
|
|
40
63
|
}
|
|
41
64
|
async accessibilityCheck(deviceId) {
|
|
42
65
|
const tree = await this.dump(deviceId);
|
|
@@ -50,4 +73,45 @@ export class UiAutomatorAdapter {
|
|
|
50
73
|
totalElements: flat.length,
|
|
51
74
|
};
|
|
52
75
|
}
|
|
76
|
+
async findWithOcrFallback(deviceId, selector, options = {}) {
|
|
77
|
+
// First try accessibility tree
|
|
78
|
+
const accessibilityResults = await this.find(deviceId, selector);
|
|
79
|
+
if (accessibilityResults.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
elements: accessibilityResults,
|
|
82
|
+
source: "accessibility",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Fall back to OCR if we have a text-based selector
|
|
86
|
+
if (selector.text || selector.textContains) {
|
|
87
|
+
const searchTerm = selector.text || selector.textContains;
|
|
88
|
+
// Take screenshot for OCR
|
|
89
|
+
const screenshotResult = await this.screenshot(deviceId, {});
|
|
90
|
+
try {
|
|
91
|
+
// Run OCR
|
|
92
|
+
const ocrResults = await extractText(screenshotResult.path);
|
|
93
|
+
const matches = searchText(ocrResults, searchTerm);
|
|
94
|
+
const result = {
|
|
95
|
+
elements: matches,
|
|
96
|
+
source: "ocr",
|
|
97
|
+
};
|
|
98
|
+
if (options.debug) {
|
|
99
|
+
result.fallbackReason = "accessibility tree had no matching text";
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
// Clean up local screenshot file
|
|
105
|
+
if (screenshotResult.path) {
|
|
106
|
+
const fs = await import("fs/promises");
|
|
107
|
+
await fs.unlink(screenshotResult.path).catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// No text selector, can't use OCR
|
|
112
|
+
return {
|
|
113
|
+
elements: [],
|
|
114
|
+
source: "accessibility",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
53
117
|
}
|
package/dist/cli/gradle.js
CHANGED
|
@@ -37,10 +37,10 @@ export function createGradleCommand() {
|
|
|
37
37
|
catch (error) {
|
|
38
38
|
const duration = "0s";
|
|
39
39
|
const cacheId = cache.generateId("build");
|
|
40
|
-
if (error instanceof ReplicantError && error.
|
|
41
|
-
cache.set(cacheId, { result: error.
|
|
40
|
+
if (error instanceof ReplicantError && error.context?.buildResult) {
|
|
41
|
+
cache.set(cacheId, { result: error.context.buildResult, fullOutput: "" }, "build", CACHE_TTLS.BUILD_OUTPUT);
|
|
42
42
|
if (options.json) {
|
|
43
|
-
console.log(JSON.stringify({ error: error.message, result: error.
|
|
43
|
+
console.log(JSON.stringify({ error: error.message, result: error.context.buildResult, cacheId }, null, 2));
|
|
44
44
|
}
|
|
45
45
|
else {
|
|
46
46
|
console.log(formatBuildFailure({
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createGradleCommand, createAdbCommand, createEmulatorCommand, createUiC
|
|
|
4
4
|
const program = new Command();
|
|
5
5
|
program
|
|
6
6
|
.name("replicant")
|
|
7
|
-
.description("Android development CLI
|
|
7
|
+
.description("Android development CLI")
|
|
8
8
|
.version("1.0.0");
|
|
9
9
|
program.addCommand(createGradleCommand());
|
|
10
10
|
program.addCommand(createAdbCommand());
|
package/dist/server.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
-
import { CacheManager, DeviceStateManager, ProcessRunner } from "./services/index.js";
|
|
2
|
+
import { CacheManager, DeviceStateManager, ProcessRunner, EnvironmentService } from "./services/index.js";
|
|
3
3
|
import { AdbAdapter, EmulatorAdapter, GradleAdapter, UiAutomatorAdapter } from "./adapters/index.js";
|
|
4
4
|
export interface ServerContext {
|
|
5
5
|
cache: CacheManager;
|
|
6
6
|
deviceState: DeviceStateManager;
|
|
7
7
|
processRunner: ProcessRunner;
|
|
8
|
+
environment: EnvironmentService;
|
|
8
9
|
adb: AdbAdapter;
|
|
9
10
|
emulator: EmulatorAdapter;
|
|
10
11
|
gradle: GradleAdapter;
|
package/dist/server.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
-
import { CacheManager, DeviceStateManager, ProcessRunner } from "./services/index.js";
|
|
4
|
+
import { CacheManager, DeviceStateManager, ProcessRunner, EnvironmentService } from "./services/index.js";
|
|
5
5
|
import { AdbAdapter, EmulatorAdapter, GradleAdapter, UiAutomatorAdapter } from "./adapters/index.js";
|
|
6
6
|
import { ReplicantError } from "./types/index.js";
|
|
7
7
|
import { cacheToolDefinition, handleCacheTool, rtfmToolDefinition, handleRtfmTool, adbDeviceToolDefinition, handleAdbDeviceTool, adbAppToolDefinition, handleAdbAppTool, adbLogcatToolDefinition, handleAdbLogcatTool, adbShellToolDefinition, handleAdbShellTool, emulatorDeviceToolDefinition, handleEmulatorDeviceTool, gradleBuildToolDefinition, handleGradleBuildTool, gradleTestToolDefinition, handleGradleTestTool, gradleListToolDefinition, handleGradleListTool, gradleGetDetailsToolDefinition, handleGradleGetDetailsTool, uiToolDefinition, handleUiTool, } from "./tools/index.js";
|
|
8
8
|
export function createServerContext() {
|
|
9
|
-
const
|
|
9
|
+
const environment = new EnvironmentService();
|
|
10
|
+
const processRunner = new ProcessRunner(environment);
|
|
10
11
|
const adb = new AdbAdapter(processRunner);
|
|
11
12
|
return {
|
|
12
13
|
cache: new CacheManager(),
|
|
13
14
|
deviceState: new DeviceStateManager(),
|
|
14
15
|
processRunner,
|
|
16
|
+
environment,
|
|
15
17
|
adb,
|
|
16
18
|
emulator: new EmulatorAdapter(processRunner),
|
|
17
19
|
gradle: new GradleAdapter(processRunner),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Device } from "../types/index.js";
|
|
2
|
+
import type { AdbAdapter } from "../adapters/adb.js";
|
|
2
3
|
export declare class DeviceStateManager {
|
|
3
4
|
private currentDevice;
|
|
4
5
|
getCurrentDevice(): Device | null;
|
|
5
6
|
setCurrentDevice(device: Device): void;
|
|
6
7
|
clearCurrentDevice(): void;
|
|
7
8
|
requireCurrentDevice(): Device;
|
|
9
|
+
ensureDevice(adb: AdbAdapter): Promise<Device>;
|
|
8
10
|
autoSelectIfSingle(devices: Device[]): boolean;
|
|
9
11
|
}
|
|
@@ -16,6 +16,24 @@ export class DeviceStateManager {
|
|
|
16
16
|
}
|
|
17
17
|
return this.currentDevice;
|
|
18
18
|
}
|
|
19
|
+
async ensureDevice(adb) {
|
|
20
|
+
// Already selected? Use it.
|
|
21
|
+
if (this.currentDevice) {
|
|
22
|
+
return this.currentDevice;
|
|
23
|
+
}
|
|
24
|
+
// Try to auto-select
|
|
25
|
+
const devices = await adb.getDevices();
|
|
26
|
+
if (devices.length === 0) {
|
|
27
|
+
throw new ReplicantError(ErrorCode.NO_DEVICES, "No devices connected", "Start an emulator with 'emulator-device start' or connect a USB device with debugging enabled");
|
|
28
|
+
}
|
|
29
|
+
if (devices.length === 1) {
|
|
30
|
+
this.currentDevice = devices[0];
|
|
31
|
+
return this.currentDevice;
|
|
32
|
+
}
|
|
33
|
+
// Multiple devices - user must choose
|
|
34
|
+
const deviceList = devices.map((d) => d.id).join(", ");
|
|
35
|
+
throw new ReplicantError(ErrorCode.MULTIPLE_DEVICES, `${devices.length} devices connected: ${deviceList}`, `Call adb-device({ operation: 'select', deviceId: '...' }) to choose one`);
|
|
36
|
+
}
|
|
19
37
|
autoSelectIfSingle(devices) {
|
|
20
38
|
if (devices.length === 1 && !this.currentDevice) {
|
|
21
39
|
this.currentDevice = devices[0];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Environment {
|
|
2
|
+
sdkPath: string | null;
|
|
3
|
+
adbPath: string | null;
|
|
4
|
+
emulatorPath: string | null;
|
|
5
|
+
platform: "darwin" | "linux" | "win32";
|
|
6
|
+
isValid: boolean;
|
|
7
|
+
issues: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare class EnvironmentService {
|
|
10
|
+
private cached;
|
|
11
|
+
detect(): Promise<Environment>;
|
|
12
|
+
getAdbPath(): Promise<string>;
|
|
13
|
+
getEmulatorPath(): Promise<string>;
|
|
14
|
+
getAvdManagerPath(): Promise<string>;
|
|
15
|
+
private findSdkPath;
|
|
16
|
+
private getSearchPaths;
|
|
17
|
+
clearCache(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { ReplicantError, ErrorCode } from "../types/index.js";
|
|
5
|
+
export class EnvironmentService {
|
|
6
|
+
cached = null;
|
|
7
|
+
async detect() {
|
|
8
|
+
if (this.cached) {
|
|
9
|
+
return this.cached;
|
|
10
|
+
}
|
|
11
|
+
const platform = os.platform();
|
|
12
|
+
const issues = [];
|
|
13
|
+
// Try to find SDK
|
|
14
|
+
const sdkPath = this.findSdkPath(platform);
|
|
15
|
+
if (!sdkPath) {
|
|
16
|
+
this.cached = {
|
|
17
|
+
sdkPath: null,
|
|
18
|
+
adbPath: null,
|
|
19
|
+
emulatorPath: null,
|
|
20
|
+
platform,
|
|
21
|
+
isValid: false,
|
|
22
|
+
issues: ["Android SDK not found. Install Android Studio or set ANDROID_HOME environment variable."],
|
|
23
|
+
};
|
|
24
|
+
return this.cached;
|
|
25
|
+
}
|
|
26
|
+
const adbPath = path.join(sdkPath, "platform-tools", "adb");
|
|
27
|
+
const emulatorPath = path.join(sdkPath, "emulator", "emulator");
|
|
28
|
+
// Verify adb exists
|
|
29
|
+
if (!fs.existsSync(adbPath)) {
|
|
30
|
+
issues.push(`adb not found at ${adbPath}`);
|
|
31
|
+
}
|
|
32
|
+
// Emulator is optional - just note if missing
|
|
33
|
+
if (!fs.existsSync(emulatorPath)) {
|
|
34
|
+
issues.push(`emulator not found at ${emulatorPath} (optional)`);
|
|
35
|
+
}
|
|
36
|
+
this.cached = {
|
|
37
|
+
sdkPath,
|
|
38
|
+
adbPath: fs.existsSync(adbPath) ? adbPath : null,
|
|
39
|
+
emulatorPath: fs.existsSync(emulatorPath) ? emulatorPath : null,
|
|
40
|
+
platform,
|
|
41
|
+
isValid: fs.existsSync(adbPath),
|
|
42
|
+
issues,
|
|
43
|
+
};
|
|
44
|
+
return this.cached;
|
|
45
|
+
}
|
|
46
|
+
async getAdbPath() {
|
|
47
|
+
const env = await this.detect();
|
|
48
|
+
if (!env.adbPath) {
|
|
49
|
+
throw new ReplicantError(ErrorCode.ADB_NOT_FOUND, "Android SDK not found", "Install Android Studio or set ANDROID_HOME environment variable", { checkedPaths: this.getSearchPaths(env.platform) });
|
|
50
|
+
}
|
|
51
|
+
return env.adbPath;
|
|
52
|
+
}
|
|
53
|
+
async getEmulatorPath() {
|
|
54
|
+
const env = await this.detect();
|
|
55
|
+
if (!env.emulatorPath) {
|
|
56
|
+
throw new ReplicantError(ErrorCode.EMULATOR_NOT_FOUND, "Android emulator not found", "Install Android Emulator via Android Studio SDK Manager");
|
|
57
|
+
}
|
|
58
|
+
return env.emulatorPath;
|
|
59
|
+
}
|
|
60
|
+
async getAvdManagerPath() {
|
|
61
|
+
const env = await this.detect();
|
|
62
|
+
if (!env.sdkPath) {
|
|
63
|
+
throw new ReplicantError(ErrorCode.SDK_NOT_FOUND, "Android SDK not found", "Install Android Studio or set ANDROID_HOME environment variable");
|
|
64
|
+
}
|
|
65
|
+
const avdManagerPath = path.join(env.sdkPath, "cmdline-tools", "latest", "bin", "avdmanager");
|
|
66
|
+
// Fallback to older location
|
|
67
|
+
const legacyPath = path.join(env.sdkPath, "tools", "bin", "avdmanager");
|
|
68
|
+
if (fs.existsSync(avdManagerPath)) {
|
|
69
|
+
return avdManagerPath;
|
|
70
|
+
}
|
|
71
|
+
if (fs.existsSync(legacyPath)) {
|
|
72
|
+
return legacyPath;
|
|
73
|
+
}
|
|
74
|
+
throw new ReplicantError(ErrorCode.SDK_NOT_FOUND, "avdmanager not found", "Install Android SDK Command-line Tools via Android Studio SDK Manager");
|
|
75
|
+
}
|
|
76
|
+
findSdkPath(platform) {
|
|
77
|
+
// 1. Check ANDROID_HOME
|
|
78
|
+
if (process.env.ANDROID_HOME) {
|
|
79
|
+
const adbPath = path.join(process.env.ANDROID_HOME, "platform-tools", "adb");
|
|
80
|
+
if (fs.existsSync(adbPath)) {
|
|
81
|
+
return process.env.ANDROID_HOME;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 2. Check ANDROID_SDK_ROOT
|
|
85
|
+
if (process.env.ANDROID_SDK_ROOT) {
|
|
86
|
+
const adbPath = path.join(process.env.ANDROID_SDK_ROOT, "platform-tools", "adb");
|
|
87
|
+
if (fs.existsSync(adbPath)) {
|
|
88
|
+
return process.env.ANDROID_SDK_ROOT;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// 3. Probe common paths
|
|
92
|
+
const searchPaths = this.getSearchPaths(platform);
|
|
93
|
+
for (const sdkPath of searchPaths) {
|
|
94
|
+
const adbPath = path.join(sdkPath, "platform-tools", "adb");
|
|
95
|
+
if (fs.existsSync(adbPath)) {
|
|
96
|
+
return sdkPath;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
getSearchPaths(platform) {
|
|
102
|
+
const home = os.homedir();
|
|
103
|
+
if (platform === "darwin") {
|
|
104
|
+
return [
|
|
105
|
+
path.join(home, "Library", "Android", "sdk"),
|
|
106
|
+
"/opt/homebrew/share/android-sdk",
|
|
107
|
+
"/usr/local/share/android-sdk",
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
if (platform === "linux") {
|
|
111
|
+
return [
|
|
112
|
+
path.join(home, "Android", "Sdk"),
|
|
113
|
+
"/opt/android-sdk",
|
|
114
|
+
"/usr/lib/android-sdk",
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
119
|
+
return [
|
|
120
|
+
path.join(localAppData, "Android", "Sdk"),
|
|
121
|
+
"C:\\Android\\sdk",
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
// Clear cache (for testing)
|
|
127
|
+
clearCache() {
|
|
128
|
+
this.cached = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/dist/services/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ProcessRunner, type RunOptions, type RunResult } from "./process-runner.js";
|
|
2
2
|
export { CacheManager, type CacheStats } from "./cache-manager.js";
|
|
3
3
|
export { DeviceStateManager } from "./device-state.js";
|
|
4
|
+
export { EnvironmentService, type Environment } from "./environment.js";
|
|
5
|
+
export * from "./ocr.js";
|
package/dist/services/index.js
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { OcrResult, OcrElement } from "../types/ocr.js";
|
|
2
|
+
export declare function extractText(imagePath: string): Promise<OcrResult[]>;
|
|
3
|
+
export declare function terminateOcr(): Promise<void>;
|
|
4
|
+
export declare function searchText(ocrResults: OcrResult[], searchTerm: string): OcrElement[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createWorker } from "tesseract.js";
|
|
2
|
+
let worker = null;
|
|
3
|
+
async function getWorker() {
|
|
4
|
+
if (!worker) {
|
|
5
|
+
worker = await createWorker("eng");
|
|
6
|
+
}
|
|
7
|
+
return worker;
|
|
8
|
+
}
|
|
9
|
+
export async function extractText(imagePath) {
|
|
10
|
+
const w = await getWorker();
|
|
11
|
+
const { data } = await w.recognize(imagePath, {}, { blocks: true });
|
|
12
|
+
const results = [];
|
|
13
|
+
// Navigate through blocks -> paragraphs -> lines -> words
|
|
14
|
+
if (data.blocks) {
|
|
15
|
+
for (const block of data.blocks) {
|
|
16
|
+
for (const paragraph of block.paragraphs) {
|
|
17
|
+
for (const line of paragraph.lines) {
|
|
18
|
+
for (const word of line.words) {
|
|
19
|
+
if (word.text.trim()) {
|
|
20
|
+
results.push({
|
|
21
|
+
text: word.text,
|
|
22
|
+
confidence: word.confidence / 100, // Normalize to 0-1
|
|
23
|
+
bounds: {
|
|
24
|
+
x0: word.bbox.x0,
|
|
25
|
+
y0: word.bbox.y0,
|
|
26
|
+
x1: word.bbox.x1,
|
|
27
|
+
y1: word.bbox.y1,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
export async function terminateOcr() {
|
|
39
|
+
if (worker) {
|
|
40
|
+
await worker.terminate();
|
|
41
|
+
worker = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function searchText(ocrResults, searchTerm) {
|
|
45
|
+
const lowerSearch = searchTerm.toLowerCase();
|
|
46
|
+
const matches = ocrResults.filter((result) => result.text.toLowerCase().includes(lowerSearch));
|
|
47
|
+
// Index represents position in filtered matches (0, 1, 2...) for use with elementIndex tap
|
|
48
|
+
// This is intentional - users tap by match index, not original OCR result position
|
|
49
|
+
return matches.map((match, index) => ({
|
|
50
|
+
index,
|
|
51
|
+
text: match.text,
|
|
52
|
+
bounds: `[${match.bounds.x0},${match.bounds.y0}][${match.bounds.x1},${match.bounds.y1}]`,
|
|
53
|
+
center: {
|
|
54
|
+
x: Math.round((match.bounds.x0 + match.bounds.x1) / 2),
|
|
55
|
+
y: Math.round((match.bounds.y0 + match.bounds.y1) / 2),
|
|
56
|
+
},
|
|
57
|
+
confidence: match.confidence,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EnvironmentService } from "./environment.js";
|
|
1
2
|
export interface RunOptions {
|
|
2
3
|
timeoutMs?: number;
|
|
3
4
|
cwd?: string;
|
|
@@ -8,8 +9,13 @@ export interface RunResult {
|
|
|
8
9
|
exitCode: number;
|
|
9
10
|
}
|
|
10
11
|
export declare class ProcessRunner {
|
|
12
|
+
private environment?;
|
|
11
13
|
private readonly defaultTimeoutMs;
|
|
12
14
|
private readonly maxTimeoutMs;
|
|
15
|
+
constructor(environment?: EnvironmentService | undefined);
|
|
13
16
|
run(command: string, args: string[], options?: RunOptions): Promise<RunResult>;
|
|
17
|
+
runAdb(args: string[], options?: RunOptions): Promise<RunResult>;
|
|
18
|
+
runEmulator(args: string[], options?: RunOptions): Promise<RunResult>;
|
|
19
|
+
runAvdManager(args: string[], options?: RunOptions): Promise<RunResult>;
|
|
14
20
|
private validateCommand;
|
|
15
21
|
}
|
|
@@ -8,8 +8,12 @@ const BLOCKED_PATTERNS = [
|
|
|
8
8
|
/\bformat\b/, // format commands
|
|
9
9
|
];
|
|
10
10
|
export class ProcessRunner {
|
|
11
|
+
environment;
|
|
11
12
|
defaultTimeoutMs = 30_000;
|
|
12
13
|
maxTimeoutMs = 120_000;
|
|
14
|
+
constructor(environment) {
|
|
15
|
+
this.environment = environment;
|
|
16
|
+
}
|
|
13
17
|
async run(command, args, options = {}) {
|
|
14
18
|
this.validateCommand(command, args);
|
|
15
19
|
const timeoutMs = Math.min(options.timeoutMs ?? this.defaultTimeoutMs, this.maxTimeoutMs);
|
|
@@ -48,6 +52,28 @@ export class ProcessRunner {
|
|
|
48
52
|
throw error;
|
|
49
53
|
}
|
|
50
54
|
}
|
|
55
|
+
async runAdb(args, options = {}) {
|
|
56
|
+
if (!this.environment) {
|
|
57
|
+
// Fallback to bare "adb" if no environment service
|
|
58
|
+
return this.run("adb", args, options);
|
|
59
|
+
}
|
|
60
|
+
const adbPath = await this.environment.getAdbPath();
|
|
61
|
+
return this.run(adbPath, args, options);
|
|
62
|
+
}
|
|
63
|
+
async runEmulator(args, options = {}) {
|
|
64
|
+
if (!this.environment) {
|
|
65
|
+
return this.run("emulator", args, options);
|
|
66
|
+
}
|
|
67
|
+
const emulatorPath = await this.environment.getEmulatorPath();
|
|
68
|
+
return this.run(emulatorPath, args, options);
|
|
69
|
+
}
|
|
70
|
+
async runAvdManager(args, options = {}) {
|
|
71
|
+
if (!this.environment) {
|
|
72
|
+
return this.run("avdmanager", args, options);
|
|
73
|
+
}
|
|
74
|
+
const avdManagerPath = await this.environment.getAvdManagerPath();
|
|
75
|
+
return this.run(avdManagerPath, args, options);
|
|
76
|
+
}
|
|
51
77
|
validateCommand(command, args) {
|
|
52
78
|
if (BLOCKED_COMMANDS.has(command)) {
|
|
53
79
|
throw new ReplicantError(ErrorCode.COMMAND_BLOCKED, `Command '${command}' is not allowed`, "Use safe commands only");
|
package/dist/tools/adb-app.js
CHANGED
|
@@ -5,7 +5,8 @@ export const adbAppInputSchema = z.object({
|
|
|
5
5
|
packageName: z.string().optional(),
|
|
6
6
|
});
|
|
7
7
|
export async function handleAdbAppTool(input, context) {
|
|
8
|
-
const
|
|
8
|
+
const device = await context.deviceState.ensureDevice(context.adb);
|
|
9
|
+
const deviceId = device.id;
|
|
9
10
|
switch (input.operation) {
|
|
10
11
|
case "install": {
|
|
11
12
|
if (!input.apkPath) {
|
|
@@ -52,7 +53,7 @@ export async function handleAdbAppTool(input, context) {
|
|
|
52
53
|
}
|
|
53
54
|
export const adbAppToolDefinition = {
|
|
54
55
|
name: "adb-app",
|
|
55
|
-
description: "Manage applications. Operations: install, uninstall, launch, stop, clear-data, list.",
|
|
56
|
+
description: "Manage applications. Auto-selects device if only one connected. Operations: install, uninstall, launch, stop, clear-data, list.",
|
|
56
57
|
inputSchema: {
|
|
57
58
|
type: "object",
|
|
58
59
|
properties: {
|
package/dist/tools/adb-device.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const adbDeviceInputSchema = z.object({
|
|
3
|
-
operation: z.enum(["list", "select", "wait", "properties"]),
|
|
3
|
+
operation: z.enum(["list", "select", "wait", "properties", "health-check"]),
|
|
4
4
|
deviceId: z.string().optional(),
|
|
5
5
|
});
|
|
6
6
|
export async function handleAdbDeviceTool(input, context) {
|
|
@@ -28,15 +28,18 @@ export async function handleAdbDeviceTool(input, context) {
|
|
|
28
28
|
return { selected: device };
|
|
29
29
|
}
|
|
30
30
|
case "wait": {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const device = input.deviceId
|
|
32
|
+
? { id: input.deviceId }
|
|
33
|
+
: await context.deviceState.ensureDevice(context.adb);
|
|
34
|
+
const deviceId = device.id;
|
|
35
35
|
await context.adb.waitForDevice(deviceId);
|
|
36
36
|
return { status: "device ready", deviceId };
|
|
37
37
|
}
|
|
38
38
|
case "properties": {
|
|
39
|
-
const
|
|
39
|
+
const device = input.deviceId
|
|
40
|
+
? { id: input.deviceId }
|
|
41
|
+
: await context.deviceState.ensureDevice(context.adb);
|
|
42
|
+
const deviceId = device.id;
|
|
40
43
|
const props = await context.adb.getProperties(deviceId);
|
|
41
44
|
return {
|
|
42
45
|
deviceId,
|
|
@@ -50,19 +53,55 @@ export async function handleAdbDeviceTool(input, context) {
|
|
|
50
53
|
allProperties: props,
|
|
51
54
|
};
|
|
52
55
|
}
|
|
56
|
+
case "health-check": {
|
|
57
|
+
const env = await context.environment.detect();
|
|
58
|
+
let adbServerRunning = false;
|
|
59
|
+
let connectedDevices = 0;
|
|
60
|
+
const warnings = [];
|
|
61
|
+
const errors = [];
|
|
62
|
+
if (!env.isValid) {
|
|
63
|
+
errors.push(...env.issues);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Test adb server
|
|
67
|
+
try {
|
|
68
|
+
const devices = await context.adb.getDevices();
|
|
69
|
+
adbServerRunning = true;
|
|
70
|
+
connectedDevices = devices.length;
|
|
71
|
+
if (devices.length === 0) {
|
|
72
|
+
warnings.push("No devices connected. Start an emulator or connect a USB device.");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
errors.push("adb server not responding. Run 'adb kill-server && adb start-server'");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
healthy: errors.length === 0,
|
|
81
|
+
environment: {
|
|
82
|
+
sdkPath: env.sdkPath,
|
|
83
|
+
adbPath: env.adbPath,
|
|
84
|
+
platform: env.platform,
|
|
85
|
+
},
|
|
86
|
+
adbServerRunning,
|
|
87
|
+
connectedDevices,
|
|
88
|
+
warnings,
|
|
89
|
+
errors,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
53
92
|
default:
|
|
54
93
|
throw new Error(`Unknown operation: ${input.operation}`);
|
|
55
94
|
}
|
|
56
95
|
}
|
|
57
96
|
export const adbDeviceToolDefinition = {
|
|
58
97
|
name: "adb-device",
|
|
59
|
-
description: "Manage device connections. Operations: list, select, wait, properties.",
|
|
98
|
+
description: "Manage device connections. Operations: list, select, wait, properties, health-check.",
|
|
60
99
|
inputSchema: {
|
|
61
100
|
type: "object",
|
|
62
101
|
properties: {
|
|
63
102
|
operation: {
|
|
64
103
|
type: "string",
|
|
65
|
-
enum: ["list", "select", "wait", "properties"],
|
|
104
|
+
enum: ["list", "select", "wait", "properties", "health-check"],
|
|
66
105
|
},
|
|
67
106
|
deviceId: { type: "string", description: "Device ID for select/wait/properties" },
|
|
68
107
|
},
|
package/dist/tools/adb-logcat.js
CHANGED
|
@@ -9,7 +9,8 @@ export const adbLogcatInputSchema = z.object({
|
|
|
9
9
|
since: z.string().optional(),
|
|
10
10
|
});
|
|
11
11
|
export async function handleAdbLogcatTool(input, context) {
|
|
12
|
-
const
|
|
12
|
+
const device = await context.deviceState.ensureDevice(context.adb);
|
|
13
|
+
const deviceId = device.id;
|
|
13
14
|
// Build filter string
|
|
14
15
|
let filter = "";
|
|
15
16
|
if (input.rawFilter) {
|
|
@@ -55,7 +56,7 @@ export async function handleAdbLogcatTool(input, context) {
|
|
|
55
56
|
}
|
|
56
57
|
export const adbLogcatToolDefinition = {
|
|
57
58
|
name: "adb-logcat",
|
|
58
|
-
description: "Read device logs. Returns summary with logId for full output.",
|
|
59
|
+
description: "Read device logs. Auto-selects device if only one connected. Returns summary with logId for full output.",
|
|
59
60
|
inputSchema: {
|
|
60
61
|
type: "object",
|
|
61
62
|
properties: {
|
package/dist/tools/adb-shell.js
CHANGED
|
@@ -4,7 +4,8 @@ export const adbShellInputSchema = z.object({
|
|
|
4
4
|
timeout: z.number().optional(),
|
|
5
5
|
});
|
|
6
6
|
export async function handleAdbShellTool(input, context) {
|
|
7
|
-
const
|
|
7
|
+
const device = await context.deviceState.ensureDevice(context.adb);
|
|
8
|
+
const deviceId = device.id;
|
|
8
9
|
const result = await context.adb.shell(deviceId, input.command, input.timeout);
|
|
9
10
|
return {
|
|
10
11
|
stdout: result.stdout,
|
|
@@ -15,7 +16,7 @@ export async function handleAdbShellTool(input, context) {
|
|
|
15
16
|
}
|
|
16
17
|
export const adbShellToolDefinition = {
|
|
17
18
|
name: "adb-shell",
|
|
18
|
-
description: "Execute shell commands with safety guards. Dangerous commands are blocked.",
|
|
19
|
+
description: "Execute shell commands with safety guards. Auto-selects device if only one connected. Dangerous commands are blocked.",
|
|
19
20
|
inputSchema: {
|
|
20
21
|
type: "object",
|
|
21
22
|
properties: {
|
|
@@ -5,8 +5,8 @@ export declare const gradleGetDetailsInputSchema: z.ZodObject<{
|
|
|
5
5
|
detailType: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
6
6
|
all: "all";
|
|
7
7
|
tasks: "tasks";
|
|
8
|
-
logs: "logs";
|
|
9
8
|
errors: "errors";
|
|
9
|
+
logs: "logs";
|
|
10
10
|
}>>>;
|
|
11
11
|
}, z.core.$strip>;
|
|
12
12
|
export type GradleGetDetailsInput = z.infer<typeof gradleGetDetailsInputSchema>;
|
package/dist/tools/ui.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare const uiInputSchema: z.ZodObject<{
|
|
|
20
20
|
elementIndex: z.ZodOptional<z.ZodNumber>;
|
|
21
21
|
text: z.ZodOptional<z.ZodString>;
|
|
22
22
|
localPath: z.ZodOptional<z.ZodString>;
|
|
23
|
+
inline: z.ZodOptional<z.ZodBoolean>;
|
|
24
|
+
debug: z.ZodOptional<z.ZodBoolean>;
|
|
23
25
|
}, z.core.$strip>;
|
|
24
26
|
export type UiInput = z.infer<typeof uiInputSchema>;
|
|
25
27
|
export declare function handleUiTool(input: UiInput, context: ServerContext): Promise<Record<string, unknown>>;
|
|
@@ -71,6 +73,14 @@ export declare const uiToolDefinition: {
|
|
|
71
73
|
type: string;
|
|
72
74
|
description: string;
|
|
73
75
|
};
|
|
76
|
+
inline: {
|
|
77
|
+
type: string;
|
|
78
|
+
description: string;
|
|
79
|
+
};
|
|
80
|
+
debug: {
|
|
81
|
+
type: string;
|
|
82
|
+
description: string;
|
|
83
|
+
};
|
|
74
84
|
};
|
|
75
85
|
required: string[];
|
|
76
86
|
};
|
package/dist/tools/ui.js
CHANGED
|
@@ -13,11 +13,26 @@ export const uiInputSchema = z.object({
|
|
|
13
13
|
elementIndex: z.number().optional(),
|
|
14
14
|
text: z.string().optional(),
|
|
15
15
|
localPath: z.string().optional(),
|
|
16
|
+
inline: z.boolean().optional(),
|
|
17
|
+
debug: z.boolean().optional(),
|
|
16
18
|
});
|
|
17
19
|
// Store last find results for elementIndex reference
|
|
20
|
+
// Updated to support both accessibility and OCR elements
|
|
18
21
|
let lastFindResults = [];
|
|
22
|
+
// Helper to get center coordinates from either element type
|
|
23
|
+
function getElementCenter(element) {
|
|
24
|
+
if ("centerX" in element) {
|
|
25
|
+
// AccessibilityNode
|
|
26
|
+
return { x: element.centerX, y: element.centerY };
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// OcrElement
|
|
30
|
+
return element.center;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
export async function handleUiTool(input, context) {
|
|
20
|
-
const
|
|
34
|
+
const device = await context.deviceState.ensureDevice(context.adb);
|
|
35
|
+
const deviceId = device.id;
|
|
21
36
|
switch (input.operation) {
|
|
22
37
|
case "dump": {
|
|
23
38
|
const tree = await context.ui.dump(deviceId);
|
|
@@ -43,6 +58,49 @@ export async function handleUiTool(input, context) {
|
|
|
43
58
|
if (!input.selector) {
|
|
44
59
|
throw new Error("selector is required for find operation");
|
|
45
60
|
}
|
|
61
|
+
const debug = input.debug ?? false;
|
|
62
|
+
// Use findWithOcrFallback for text-based selectors
|
|
63
|
+
if (input.selector.text || input.selector.textContains) {
|
|
64
|
+
const result = await context.ui.findWithOcrFallback(deviceId, input.selector, { debug });
|
|
65
|
+
lastFindResults = result.elements;
|
|
66
|
+
const response = {
|
|
67
|
+
elements: result.elements.map((el, index) => {
|
|
68
|
+
if ("centerX" in el) {
|
|
69
|
+
// AccessibilityNode
|
|
70
|
+
return {
|
|
71
|
+
index,
|
|
72
|
+
text: el.text,
|
|
73
|
+
resourceId: el.resourceId,
|
|
74
|
+
className: el.className,
|
|
75
|
+
centerX: el.centerX,
|
|
76
|
+
centerY: el.centerY,
|
|
77
|
+
bounds: el.bounds,
|
|
78
|
+
clickable: el.clickable,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// OcrElement
|
|
83
|
+
return {
|
|
84
|
+
index,
|
|
85
|
+
text: el.text,
|
|
86
|
+
center: el.center,
|
|
87
|
+
bounds: el.bounds,
|
|
88
|
+
confidence: debug ? el.confidence : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
count: result.elements.length,
|
|
93
|
+
deviceId,
|
|
94
|
+
};
|
|
95
|
+
if (debug) {
|
|
96
|
+
response.source = result.source;
|
|
97
|
+
if (result.fallbackReason) {
|
|
98
|
+
response.fallbackReason = result.fallbackReason;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return response;
|
|
102
|
+
}
|
|
103
|
+
// Non-text selectors use regular find (no OCR fallback)
|
|
46
104
|
const elements = await context.ui.find(deviceId, input.selector);
|
|
47
105
|
lastFindResults = elements;
|
|
48
106
|
return {
|
|
@@ -67,8 +125,9 @@ export async function handleUiTool(input, context) {
|
|
|
67
125
|
throw new Error(`Element at index ${input.elementIndex} not found. Run 'find' first.`);
|
|
68
126
|
}
|
|
69
127
|
const element = lastFindResults[input.elementIndex];
|
|
70
|
-
|
|
71
|
-
|
|
128
|
+
const center = getElementCenter(element);
|
|
129
|
+
x = center.x;
|
|
130
|
+
y = center.y;
|
|
72
131
|
}
|
|
73
132
|
else if (input.x !== undefined && input.y !== undefined) {
|
|
74
133
|
x = input.x;
|
|
@@ -88,9 +147,11 @@ export async function handleUiTool(input, context) {
|
|
|
88
147
|
return { input: input.text, deviceId };
|
|
89
148
|
}
|
|
90
149
|
case "screenshot": {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
150
|
+
const result = await context.ui.screenshot(deviceId, {
|
|
151
|
+
localPath: input.localPath,
|
|
152
|
+
inline: input.inline,
|
|
153
|
+
});
|
|
154
|
+
return { ...result, deviceId };
|
|
94
155
|
}
|
|
95
156
|
case "accessibility-check": {
|
|
96
157
|
const result = await context.ui.accessibilityCheck(deviceId);
|
|
@@ -102,7 +163,7 @@ export async function handleUiTool(input, context) {
|
|
|
102
163
|
}
|
|
103
164
|
export const uiToolDefinition = {
|
|
104
165
|
name: "ui",
|
|
105
|
-
description: "Interact with app UI via accessibility tree. Operations: dump, find, tap, input, screenshot, accessibility-check.",
|
|
166
|
+
description: "Interact with app UI via accessibility tree. Auto-selects device if only one connected. Operations: dump, find, tap, input, screenshot, accessibility-check.",
|
|
106
167
|
inputSchema: {
|
|
107
168
|
type: "object",
|
|
108
169
|
properties: {
|
|
@@ -124,7 +185,9 @@ export const uiToolDefinition = {
|
|
|
124
185
|
y: { type: "number", description: "Y coordinate (for tap)" },
|
|
125
186
|
elementIndex: { type: "number", description: "Element index from last find (for tap)" },
|
|
126
187
|
text: { type: "string", description: "Text to input" },
|
|
127
|
-
localPath: { type: "string", description: "Local path for screenshot" },
|
|
188
|
+
localPath: { type: "string", description: "Local path for screenshot (default: /tmp/replicant-screenshot-{timestamp}.png)" },
|
|
189
|
+
inline: { type: "boolean", description: "Return base64 instead of file path (token-heavy, use sparingly)" },
|
|
190
|
+
debug: { type: "boolean", description: "Include source (accessibility/ocr) and confidence in response" },
|
|
128
191
|
},
|
|
129
192
|
required: ["operation"],
|
|
130
193
|
},
|
package/dist/types/errors.d.ts
CHANGED
|
@@ -9,13 +9,30 @@ export declare const ErrorCode: {
|
|
|
9
9
|
readonly PACKAGE_NOT_FOUND: "PACKAGE_NOT_FOUND";
|
|
10
10
|
readonly INSTALL_FAILED: "INSTALL_FAILED";
|
|
11
11
|
readonly AVD_NOT_FOUND: "AVD_NOT_FOUND";
|
|
12
|
+
readonly EMULATOR_NOT_FOUND: "EMULATOR_NOT_FOUND";
|
|
12
13
|
readonly EMULATOR_START_FAILED: "EMULATOR_START_FAILED";
|
|
13
14
|
readonly SNAPSHOT_NOT_FOUND: "SNAPSHOT_NOT_FOUND";
|
|
14
15
|
readonly COMMAND_BLOCKED: "COMMAND_BLOCKED";
|
|
15
16
|
readonly TIMEOUT: "TIMEOUT";
|
|
16
17
|
readonly CACHE_MISS: "CACHE_MISS";
|
|
18
|
+
readonly SDK_NOT_FOUND: "SDK_NOT_FOUND";
|
|
19
|
+
readonly ADB_NOT_FOUND: "ADB_NOT_FOUND";
|
|
20
|
+
readonly ADB_NOT_EXECUTABLE: "ADB_NOT_EXECUTABLE";
|
|
21
|
+
readonly ADB_SERVER_ERROR: "ADB_SERVER_ERROR";
|
|
22
|
+
readonly NO_DEVICES: "NO_DEVICES";
|
|
23
|
+
readonly MULTIPLE_DEVICES: "MULTIPLE_DEVICES";
|
|
24
|
+
readonly SCREENSHOT_FAILED: "SCREENSHOT_FAILED";
|
|
25
|
+
readonly PULL_FAILED: "PULL_FAILED";
|
|
26
|
+
readonly HEALTH_CHECK_FAILED: "HEALTH_CHECK_FAILED";
|
|
17
27
|
};
|
|
18
28
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
29
|
+
export interface ErrorContext {
|
|
30
|
+
command?: string;
|
|
31
|
+
exitCode?: number;
|
|
32
|
+
stderr?: string;
|
|
33
|
+
checkedPaths?: string[];
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
19
36
|
export interface ToolError {
|
|
20
37
|
error: ErrorCode;
|
|
21
38
|
message: string;
|
|
@@ -25,7 +42,13 @@ export interface ToolError {
|
|
|
25
42
|
export declare class ReplicantError extends Error {
|
|
26
43
|
readonly code: ErrorCode;
|
|
27
44
|
readonly suggestion?: string | undefined;
|
|
28
|
-
readonly
|
|
29
|
-
constructor(code: ErrorCode, message: string, suggestion?: string | undefined,
|
|
45
|
+
readonly context?: ErrorContext | undefined;
|
|
46
|
+
constructor(code: ErrorCode, message: string, suggestion?: string | undefined, context?: ErrorContext | undefined);
|
|
47
|
+
toJSON(): {
|
|
48
|
+
error: ErrorCode;
|
|
49
|
+
message: string;
|
|
50
|
+
suggestion: string | undefined;
|
|
51
|
+
context: ErrorContext | undefined;
|
|
52
|
+
};
|
|
30
53
|
toToolError(): ToolError;
|
|
31
54
|
}
|
package/dist/types/errors.js
CHANGED
|
@@ -13,6 +13,7 @@ export const ErrorCode = {
|
|
|
13
13
|
INSTALL_FAILED: "INSTALL_FAILED",
|
|
14
14
|
// Emulator errors
|
|
15
15
|
AVD_NOT_FOUND: "AVD_NOT_FOUND",
|
|
16
|
+
EMULATOR_NOT_FOUND: "EMULATOR_NOT_FOUND",
|
|
16
17
|
EMULATOR_START_FAILED: "EMULATOR_START_FAILED",
|
|
17
18
|
SNAPSHOT_NOT_FOUND: "SNAPSHOT_NOT_FOUND",
|
|
18
19
|
// Safety errors
|
|
@@ -20,24 +21,42 @@ export const ErrorCode = {
|
|
|
20
21
|
TIMEOUT: "TIMEOUT",
|
|
21
22
|
// Cache errors
|
|
22
23
|
CACHE_MISS: "CACHE_MISS",
|
|
24
|
+
// New "Just Works" UX error codes
|
|
25
|
+
SDK_NOT_FOUND: "SDK_NOT_FOUND",
|
|
26
|
+
ADB_NOT_FOUND: "ADB_NOT_FOUND",
|
|
27
|
+
ADB_NOT_EXECUTABLE: "ADB_NOT_EXECUTABLE",
|
|
28
|
+
ADB_SERVER_ERROR: "ADB_SERVER_ERROR",
|
|
29
|
+
NO_DEVICES: "NO_DEVICES",
|
|
30
|
+
MULTIPLE_DEVICES: "MULTIPLE_DEVICES",
|
|
31
|
+
SCREENSHOT_FAILED: "SCREENSHOT_FAILED",
|
|
32
|
+
PULL_FAILED: "PULL_FAILED",
|
|
33
|
+
HEALTH_CHECK_FAILED: "HEALTH_CHECK_FAILED",
|
|
23
34
|
};
|
|
24
35
|
export class ReplicantError extends Error {
|
|
25
36
|
code;
|
|
26
37
|
suggestion;
|
|
27
|
-
|
|
28
|
-
constructor(code, message, suggestion,
|
|
38
|
+
context;
|
|
39
|
+
constructor(code, message, suggestion, context) {
|
|
29
40
|
super(message);
|
|
30
41
|
this.code = code;
|
|
31
42
|
this.suggestion = suggestion;
|
|
32
|
-
this.
|
|
43
|
+
this.context = context;
|
|
33
44
|
this.name = "ReplicantError";
|
|
34
45
|
}
|
|
46
|
+
toJSON() {
|
|
47
|
+
return {
|
|
48
|
+
error: this.code,
|
|
49
|
+
message: this.message,
|
|
50
|
+
suggestion: this.suggestion,
|
|
51
|
+
context: this.context,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
35
54
|
toToolError() {
|
|
36
55
|
return {
|
|
37
56
|
error: this.code,
|
|
38
57
|
message: this.message,
|
|
39
58
|
suggestion: this.suggestion,
|
|
40
|
-
details: this.
|
|
59
|
+
details: this.context,
|
|
41
60
|
};
|
|
42
61
|
}
|
|
43
62
|
}
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface OcrBounds {
|
|
2
|
+
x0: number;
|
|
3
|
+
y0: number;
|
|
4
|
+
x1: number;
|
|
5
|
+
y1: number;
|
|
6
|
+
}
|
|
7
|
+
export interface OcrResult {
|
|
8
|
+
text: string;
|
|
9
|
+
confidence: number;
|
|
10
|
+
bounds: OcrBounds;
|
|
11
|
+
}
|
|
12
|
+
export interface OcrElement {
|
|
13
|
+
index: number;
|
|
14
|
+
text: string;
|
|
15
|
+
bounds: string;
|
|
16
|
+
center: {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
};
|
|
20
|
+
confidence: number;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replicant-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Android MCP server for AI-assisted Android development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
"test:device": "tsx scripts/real-device-test.ts",
|
|
21
21
|
"start": "node dist/index.js",
|
|
22
22
|
"validate": "npm run build && npm run test -- --run",
|
|
23
|
-
"install-skill": "bash scripts/install-skill.sh",
|
|
24
23
|
"prepublishOnly": "npm run build && npm test -- --run"
|
|
25
24
|
},
|
|
26
25
|
"keywords": [
|
|
@@ -52,6 +51,7 @@
|
|
|
52
51
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
53
52
|
"commander": "^14.0.2",
|
|
54
53
|
"execa": "^9.6.1",
|
|
54
|
+
"tesseract.js": "^7.0.0",
|
|
55
55
|
"zod": "^4.3.5"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|