ta-studio-mcp 1.3.0 → 1.4.0
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 +24 -141
- package/__init__.py +14 -0
- package/auth.py +53 -0
- package/bin/cli.js +69 -0
- package/emulator_relay.py +241 -0
- package/main.py +105 -0
- package/package.json +13 -47
- package/stream.py +110 -0
- package/ws_client.py +135 -0
- package/LICENSE +0 -22
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -28
- package/dist/index.js.map +0 -1
- package/dist/knowledge/codebase-map.d.ts +0 -6
- package/dist/knowledge/codebase-map.d.ts.map +0 -1
- package/dist/knowledge/codebase-map.js +0 -121
- package/dist/knowledge/codebase-map.js.map +0 -1
- package/dist/knowledge/conventions.d.ts +0 -6
- package/dist/knowledge/conventions.d.ts.map +0 -1
- package/dist/knowledge/conventions.js +0 -101
- package/dist/knowledge/conventions.js.map +0 -1
- package/dist/knowledge/known-issues.d.ts +0 -17
- package/dist/knowledge/known-issues.d.ts.map +0 -1
- package/dist/knowledge/known-issues.js +0 -103
- package/dist/knowledge/known-issues.js.map +0 -1
- package/dist/knowledge/methodology.d.ts +0 -6
- package/dist/knowledge/methodology.d.ts.map +0 -1
- package/dist/knowledge/methodology.js +0 -235
- package/dist/knowledge/methodology.js.map +0 -1
- package/dist/knowledge/workflows.d.ts +0 -7
- package/dist/knowledge/workflows.d.ts.map +0 -1
- package/dist/knowledge/workflows.js +0 -146
- package/dist/knowledge/workflows.js.map +0 -1
- package/dist/tools/register-all.d.ts +0 -6
- package/dist/tools/register-all.d.ts.map +0 -1
- package/dist/tools/register-all.js +0 -104
- package/dist/tools/register-all.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,144 +1,27 @@
|
|
|
1
|
-
# ta-studio-mcp
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
### Phase 1: Direct Extraction
|
|
22
|
-
We use the Figma REST API with a specific recursive traversal logic:
|
|
23
|
-
- **Depth-3 Extraction**: `DOC` -> `CANVAS` -> `SECTION` -> `FRAME`.
|
|
24
|
-
- **Logic**: We stop at the Frame level to capture screens. Critical insight: standard `depth=2` extractions only reach the Section level, often missing the actual UI Frames nested within.
|
|
25
|
-
- **Node Analysis**: Every frame is analyzed for its name, `transitionNodeID` (prototype links), and spatial coordinates.
|
|
26
|
-
|
|
27
|
-
### Phase 2: Multi-Signal Priority Clustering
|
|
28
|
-
To group screens into logical user flows (e.g., "Onboarding", "Checkout"), we use a cascade of grouping signals in order of reliability:
|
|
29
|
-
1. **Section-Based**: (Highest Priority) Uses Figma's native `Section` grouping if available.
|
|
30
|
-
2. **Prototype Connections**: Uses Union-Find algorithm on `transitionNodeID` links to group screens that are functionally connected.
|
|
31
|
-
3. **Name-Prefix Matching**: Split by common naming separators like ` / `, ` - `, or ` — `.
|
|
32
|
-
4. **Spatial Clustering**: (Lowest Priority) Groups by proximity using Y-binning and X-gap splitting.
|
|
33
|
-
|
|
34
|
-
### Phase 3: Visual Overlay & CV Fallback
|
|
35
|
-
- **PIL Visualizer**: Generates a high-contrast overlay with 12 distinct colors, semi-transparent fills (alpha=40), and strong outlines (alpha=200).
|
|
36
|
-
- **Rate-Limit Fallback**: When the Figma Images API is rate-limited (common with `429` errors), we switch to a Computer Vision (CV) pipeline:
|
|
37
|
-
- **Brightness Thresholding**: Identify sections and frames by detecting canvas brightness deltas (>80 for sections, >100 for frames).
|
|
38
|
-
- **Morphological Opening**: Uses `scipy.ndimage` to clean up noise and bridge gaps in detected outlines.
|
|
39
|
-
- **Connected Component Analysis**: Reconstructs frame hierarchies from pixel data when API metadata is unavailable.
|
|
40
|
-
|
|
41
|
-
Key files:
|
|
42
|
-
- `backend/app/figma/flow_analyzer.py` (707 lines)
|
|
43
|
-
- `scripts/figma_cv_overlay.py` (162 lines)
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## 📱 Device Testing & Simulation Lifecycle
|
|
48
|
-
|
|
49
|
-
The TA Studio backend manages thousands of automated simulation steps across a fleet of Android emulators.
|
|
50
|
-
|
|
51
|
-
### Concurrency & Thread Safety
|
|
52
|
-
- **Async Execution**: Each device task runs in its own `asyncio.Task`.
|
|
53
|
-
- **Semaphore Guard**: A global `asyncio.Semaphore(max_concurrent)` prevents host resource exhaustion.
|
|
54
|
-
- **Simulation Lock**: Per-simulation `asyncio.Lock` ensures that result indexing is thread-safe and deterministic.
|
|
55
|
-
|
|
56
|
-
### Simulation States
|
|
57
|
-
`queued` -> `running` -> `completed` | `failed` | `cancelled`
|
|
58
|
-
- **Auto-Purge**: 24h age-out or 100 total simulations retention limit to prevent memory bloat.
|
|
59
|
-
- **Device Auto-Select**: Automatically ranks devices (`emulator-5554` first, then general emulators, then physical devices).
|
|
60
|
-
|
|
61
|
-
Key file: `backend/app/agents/coordinator/coordinator_service.py`
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
|
|
65
|
-
## 👁️ Vision Click & Agentic Visual Reasoning
|
|
66
|
-
|
|
67
|
-
When the accessibility tree (`list_elements_on_screen`) fails—common in canvas-based UIs or custom views—we switch to Agentic Vision.
|
|
68
|
-
|
|
69
|
-
### Two-Layer Architecture
|
|
70
|
-
1. **Layer 1: SoM Annotation**: Deterministic, fast (<100ms) annotation of the screenshot using color-coded bounding boxes derived from the element list.
|
|
71
|
-
2. **Layer 2: GPT-5.2 Agentic Vision**:
|
|
72
|
-
- **Think-Act-Observe**: GPT-5.2 generates specialized Python code and runs it in a `LocalCodeExecutor`.
|
|
73
|
-
- **Analysis**: The agent analyzes the SoM-annotated image + the element metadata to find the target.
|
|
74
|
-
- **Feedback**: Results are fed back into the OAVR loop for final execution.
|
|
75
|
-
|
|
76
|
-
Key file: `backend/app/agents/device_testing/agentic_vision_service.py` (847 lines)
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## 🔗 Mobile MCP & ADB Fallback
|
|
81
|
-
|
|
82
|
-
`ta-studio-mcp` provides the bridge to `mobile-mcp` but with a critical safety layer for production stability.
|
|
83
|
-
|
|
84
|
-
### The v0.0.36 Bug Fix
|
|
85
|
-
Mobile MCP v0.0.36 has a critical flaw: it fails to detect *any* device if *one* device in the list is offline.
|
|
86
|
-
- **The Solution**: Our `MobileMCPClient` implements a 1:1 ADB bridge fallback.
|
|
87
|
-
- **ADB Commands**: Uses `exec-out screencap -p` for fast PNG streaming and `uiautomator dump /dev/tty` for zero-file-I/O UI tree extraction.
|
|
88
|
-
|
|
89
|
-
Key file: `backend/app/agents/device_testing/mobile_mcp_client.py`
|
|
90
|
-
|
|
91
|
-
---
|
|
92
|
-
|
|
93
|
-
## ⚡ Flicker Detection & Performance Metrics
|
|
94
|
-
|
|
95
|
-
We detect regressions that occur faster than standard screenshot intervals (16-200ms).
|
|
96
|
-
|
|
97
|
-
### 4-Layer Flicker Pipeline
|
|
98
|
-
1. **Trigger**: High-speed `screenrecord` (10s limit).
|
|
99
|
-
2. **Extraction**: `ffmpeg` scene filter (`gt(scene,0.003)`).
|
|
100
|
-
3. **Analysis**: Consecutive frame SSIM (Structural Similarity Index) calculation. SSIM drops > 0.15 are flagged.
|
|
101
|
-
4. **Verification**: GPT-5.2 Vision confirms the flicker and generates a video artifact for regression triage.
|
|
102
|
-
|
|
103
|
-
Key file: `backend/app/agents/device_testing/flicker_detection_service.py`
|
|
104
|
-
|
|
105
|
-
---
|
|
106
|
-
|
|
107
|
-
## 🐞 Critical Bug Fixes (Implementation Level)
|
|
108
|
-
|
|
109
|
-
| Severity | Issue | Root Cause & Expert Fix |
|
|
110
|
-
|----------|-------|-------------------------|
|
|
111
|
-
| **CRITICAL** | Bbox Misalignment | **RC**: 45% Scaling Delta between native vs JPEG. **Fix**: Map coords via `img_width / native_width`. |
|
|
112
|
-
| **CRITICAL** | Async to_thread | **RC**: Collision when passing `async` functions to `to_thread`. **Fix**: Ensure target is a plain function. |
|
|
113
|
-
| **CRITICAL** | Race Condition | **RC**: Parallel tool calls in device sessions. **Fix**: `parallel_tool_calls=False` is mandatory. |
|
|
114
|
-
| **HIGH** | Simulation Leak | **RC**: Context persistence leading to memory fail. **Fix**: 24h retention + indexing lock. |
|
|
115
|
-
| **HIGH** | Figma API 429 | **RC**: Heavy polling on Figma API. **Fix**: Integrated CV Brightness thresholding fallback. |
|
|
116
|
-
| **MEDIUM** | SSIM False Positive | **RC**: Normal UI transitions. **Fix**: Increased sensitivity threshold to 0.15. |
|
|
117
|
-
|
|
118
|
-
---
|
|
119
|
-
|
|
120
|
-
## 🔄 Core Workflows
|
|
121
|
-
|
|
122
|
-
### The Ralph Loop (Closed-Loop Verification)
|
|
123
|
-
1. **CODE** -> Implement the feature or fix.
|
|
124
|
-
2. **LINT** -> Run `mypy` and `eslint`.
|
|
125
|
-
3. **UNIT TEST** -> Verify the module in isolation.
|
|
126
|
-
4. **CHECK ASYNC** -> Explicitly audit `to_thread` safety and concurrency locks.
|
|
127
|
-
5. **VERIFY HUD** -> Launch the emulator and watch the real-time Stream HUD during autonomous execution.
|
|
128
|
-
|
|
129
|
-
---
|
|
130
|
-
|
|
131
|
-
## 📦 Installation & Setup
|
|
132
|
-
|
|
133
|
-
### Claude Desktop
|
|
134
|
-
```bash
|
|
135
|
-
claude mcp add ta-studio -- npx -y ta-studio-mcp@latest
|
|
1
|
+
# ta-studio-mcp
|
|
2
|
+
|
|
3
|
+
Thin local relay for TA Studio. Connects your machine to the TA Studio server via outbound WebSocket.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
Add to your Claude Code `.mcp.json`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"ta-studio": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["ta-studio-mcp@latest"],
|
|
15
|
+
"env": {
|
|
16
|
+
"TA_API_KEY": "sk-your-key"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
136
21
|
```
|
|
137
22
|
|
|
138
|
-
|
|
139
|
-
Add `npx -y ta-studio-mcp@latest` as a command-type MCP server.
|
|
140
|
-
|
|
141
|
-
---
|
|
23
|
+
## Requirements
|
|
142
24
|
|
|
143
|
-
|
|
144
|
-
|
|
25
|
+
- Python 3.10+
|
|
26
|
+
- `websockets` pip package (`pip install websockets`)
|
|
27
|
+
- Android SDK with ADB (for emulator control)
|
package/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TA Studio MCP — Thin local relay package.
|
|
3
|
+
|
|
4
|
+
This package runs on the user's machine and connects OUT to the TA Studio
|
|
5
|
+
server via outbound WebSocket. No ports are opened. No tunnel required.
|
|
6
|
+
|
|
7
|
+
Components:
|
|
8
|
+
- ws_client: Outbound WebSocket connection with auto-reconnect
|
|
9
|
+
- auth: API key authentication for the handshake
|
|
10
|
+
- emulator_relay: Receives and executes ADB commands from server
|
|
11
|
+
- stream: Captures and streams emulator frames to server
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
package/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TA Studio — API key authentication for outbound WebSocket handshake.
|
|
3
|
+
|
|
4
|
+
The thin relay authenticates with the TA Studio server by sending an API key
|
|
5
|
+
during the WebSocket upgrade. The server validates the key and associates the
|
|
6
|
+
connection with the user's account.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# API key resolution
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def get_api_key() -> str:
|
|
23
|
+
"""Return the TA Studio API key from environment or local config.
|
|
24
|
+
|
|
25
|
+
Resolution order:
|
|
26
|
+
1. TA_API_KEY environment variable
|
|
27
|
+
2. TA_MCP_TOKEN environment variable (legacy compat)
|
|
28
|
+
3. ~/.ta-studio/config.json → api_key field
|
|
29
|
+
"""
|
|
30
|
+
key = os.getenv("TA_API_KEY") or os.getenv("TA_MCP_TOKEN", "")
|
|
31
|
+
if key:
|
|
32
|
+
return key.strip()
|
|
33
|
+
|
|
34
|
+
config_path = os.path.expanduser("~/.ta-studio/config.json")
|
|
35
|
+
try:
|
|
36
|
+
with open(config_path) as f:
|
|
37
|
+
data = json.load(f)
|
|
38
|
+
return (data.get("api_key") or "").strip()
|
|
39
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_auth_headers(api_key: Optional[str] = None) -> dict[str, str]:
|
|
46
|
+
"""Return headers dict for the WebSocket upgrade request."""
|
|
47
|
+
key = api_key or get_api_key()
|
|
48
|
+
if not key:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"No TA Studio API key found. Set TA_API_KEY env var or run the "
|
|
51
|
+
"install script to configure ~/.ta-studio/config.json"
|
|
52
|
+
)
|
|
53
|
+
return {"Authorization": f"Bearer {key}"}
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawn, execFileSync } = require("child_process");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const PKG_DIR = path.resolve(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
// --- Locate a working Python 3 interpreter -------------------------------- //
|
|
10
|
+
|
|
11
|
+
function findPython() {
|
|
12
|
+
for (const bin of ["python3", "python"]) {
|
|
13
|
+
try {
|
|
14
|
+
const ver = execFileSync(bin, ["--version"], {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
}).trim();
|
|
18
|
+
const major = parseInt((ver.match(/(\d+)\./) || [])[1], 10);
|
|
19
|
+
if (major >= 3) return bin;
|
|
20
|
+
} catch {
|
|
21
|
+
// not found — try next
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Main ----------------------------------------------------------------- //
|
|
28
|
+
|
|
29
|
+
const python = findPython();
|
|
30
|
+
|
|
31
|
+
if (!python) {
|
|
32
|
+
process.stderr.write(
|
|
33
|
+
[
|
|
34
|
+
"",
|
|
35
|
+
" ta-studio-mcp: Python 3 not found.",
|
|
36
|
+
"",
|
|
37
|
+
" This relay requires Python 3.10+ with the 'websockets' package.",
|
|
38
|
+
" Install Python from https://www.python.org/downloads/ and then run:",
|
|
39
|
+
"",
|
|
40
|
+
" pip install websockets",
|
|
41
|
+
"",
|
|
42
|
+
"",
|
|
43
|
+
].join("\n")
|
|
44
|
+
);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const child = spawn(python, [path.join(PKG_DIR, "main.py")], {
|
|
49
|
+
stdio: "inherit",
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
PYTHONPATH: PKG_DIR + (process.env.PYTHONPATH ? path.delimiter + process.env.PYTHONPATH : ""),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Forward signals so the Python process shuts down cleanly.
|
|
57
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
58
|
+
process.on(sig, () => {
|
|
59
|
+
child.kill(sig);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
child.on("exit", (code, signal) => {
|
|
64
|
+
if (signal) {
|
|
65
|
+
process.kill(process.pid, signal);
|
|
66
|
+
} else {
|
|
67
|
+
process.exit(code ?? 1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TA Studio — Emulator relay.
|
|
3
|
+
|
|
4
|
+
Receives emulator commands from the TA Studio server via WebSocket and
|
|
5
|
+
executes them locally via ADB. Results are sent back through the same
|
|
6
|
+
connection.
|
|
7
|
+
|
|
8
|
+
Supported commands:
|
|
9
|
+
- adb_shell: run an ADB shell command
|
|
10
|
+
- adb_install: install an APK
|
|
11
|
+
- launch_emulator: start an emulator AVD
|
|
12
|
+
- list_devices: list connected ADB devices
|
|
13
|
+
- tap / swipe / type_text / press_key: input events
|
|
14
|
+
- screenshot: capture and return a screenshot
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import base64
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _adb_path() -> str:
|
|
31
|
+
"""Resolve ADB binary path."""
|
|
32
|
+
android_home = os.environ.get(
|
|
33
|
+
"ANDROID_HOME",
|
|
34
|
+
os.path.expanduser("~/Library/Android/sdk"),
|
|
35
|
+
)
|
|
36
|
+
adb = os.path.join(android_home, "platform-tools", "adb")
|
|
37
|
+
if os.path.isfile(adb):
|
|
38
|
+
return adb
|
|
39
|
+
# Fallback to PATH
|
|
40
|
+
found = shutil.which("adb")
|
|
41
|
+
return found or "adb"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _run_adb(*args: str, device: str | None = None) -> dict[str, Any]:
|
|
45
|
+
"""Run an ADB command and return stdout/stderr/returncode."""
|
|
46
|
+
cmd = [_adb_path()]
|
|
47
|
+
if device:
|
|
48
|
+
cmd += ["-s", device]
|
|
49
|
+
cmd += list(args)
|
|
50
|
+
|
|
51
|
+
proc = await asyncio.create_subprocess_exec(
|
|
52
|
+
*cmd,
|
|
53
|
+
stdout=asyncio.subprocess.PIPE,
|
|
54
|
+
stderr=asyncio.subprocess.PIPE,
|
|
55
|
+
)
|
|
56
|
+
stdout, stderr = await proc.communicate()
|
|
57
|
+
return {
|
|
58
|
+
"stdout": stdout.decode(errors="replace"),
|
|
59
|
+
"stderr": stderr.decode(errors="replace"),
|
|
60
|
+
"returncode": proc.returncode,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Command handlers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def handle_list_devices(_payload: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""List connected ADB devices."""
|
|
71
|
+
result = await _run_adb("devices", "-l")
|
|
72
|
+
lines = result["stdout"].strip().split("\n")[1:] # skip header
|
|
73
|
+
devices = []
|
|
74
|
+
for line in lines:
|
|
75
|
+
parts = line.split()
|
|
76
|
+
if len(parts) >= 2:
|
|
77
|
+
devices.append({"serial": parts[0], "state": parts[1]})
|
|
78
|
+
return {"devices": devices}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def handle_adb_shell(payload: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""Run an ADB shell command."""
|
|
83
|
+
command = payload.get("command", "")
|
|
84
|
+
device = payload.get("device")
|
|
85
|
+
if not command:
|
|
86
|
+
return {"error": "No command provided"}
|
|
87
|
+
return await _run_adb("shell", command, device=device)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def handle_adb_install(payload: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
"""Install an APK on a device."""
|
|
92
|
+
apk_path = payload.get("apk_path", "")
|
|
93
|
+
device = payload.get("device")
|
|
94
|
+
if not apk_path or not os.path.isfile(apk_path):
|
|
95
|
+
return {"error": f"APK not found: {apk_path}"}
|
|
96
|
+
return await _run_adb("install", "-r", apk_path, device=device)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def handle_launch_emulator(payload: dict[str, Any]) -> dict[str, Any]:
|
|
100
|
+
"""Launch an Android emulator AVD."""
|
|
101
|
+
avd_name = payload.get("avd_name", "")
|
|
102
|
+
android_home = os.environ.get(
|
|
103
|
+
"ANDROID_HOME",
|
|
104
|
+
os.path.expanduser("~/Library/Android/sdk"),
|
|
105
|
+
)
|
|
106
|
+
emulator_bin = os.path.join(android_home, "emulator", "emulator")
|
|
107
|
+
if not os.path.isfile(emulator_bin):
|
|
108
|
+
return {"error": "Emulator binary not found"}
|
|
109
|
+
|
|
110
|
+
if not avd_name:
|
|
111
|
+
# List available AVDs and pick the first
|
|
112
|
+
proc = await asyncio.create_subprocess_exec(
|
|
113
|
+
emulator_bin, "-list-avds",
|
|
114
|
+
stdout=asyncio.subprocess.PIPE,
|
|
115
|
+
stderr=asyncio.subprocess.PIPE,
|
|
116
|
+
)
|
|
117
|
+
stdout, _ = await proc.communicate()
|
|
118
|
+
avds = [l.strip() for l in stdout.decode().strip().split("\n") if l.strip()]
|
|
119
|
+
if not avds:
|
|
120
|
+
return {"error": "No AVDs available"}
|
|
121
|
+
avd_name = avds[0]
|
|
122
|
+
|
|
123
|
+
# Launch in background
|
|
124
|
+
proc = await asyncio.create_subprocess_exec(
|
|
125
|
+
emulator_bin,
|
|
126
|
+
"-avd", avd_name,
|
|
127
|
+
"-no-snapshot",
|
|
128
|
+
"-gpu", "swiftshader_indirect",
|
|
129
|
+
"-no-boot-anim",
|
|
130
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
131
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
132
|
+
)
|
|
133
|
+
return {"avd_name": avd_name, "pid": proc.pid, "status": "launching"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def handle_screenshot(payload: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
"""Capture a screenshot and return as base64 PNG."""
|
|
138
|
+
device = payload.get("device")
|
|
139
|
+
result = await _run_adb("exec-out", "screencap", "-p", device=device)
|
|
140
|
+
if result["returncode"] != 0:
|
|
141
|
+
return {"error": result["stderr"]}
|
|
142
|
+
# screencap -p via exec-out returns raw PNG bytes in stdout
|
|
143
|
+
# Re-run with raw output
|
|
144
|
+
cmd = [_adb_path()]
|
|
145
|
+
if device:
|
|
146
|
+
cmd += ["-s", device]
|
|
147
|
+
cmd += ["exec-out", "screencap", "-p"]
|
|
148
|
+
|
|
149
|
+
proc = await asyncio.create_subprocess_exec(
|
|
150
|
+
*cmd,
|
|
151
|
+
stdout=asyncio.subprocess.PIPE,
|
|
152
|
+
stderr=asyncio.subprocess.PIPE,
|
|
153
|
+
)
|
|
154
|
+
stdout, stderr = await proc.communicate()
|
|
155
|
+
if proc.returncode != 0:
|
|
156
|
+
return {"error": stderr.decode(errors="replace")}
|
|
157
|
+
|
|
158
|
+
return {"image_base64": base64.b64encode(stdout).decode(), "format": "png"}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def handle_tap(payload: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
"""Tap at coordinates."""
|
|
163
|
+
x, y = payload.get("x", 0), payload.get("y", 0)
|
|
164
|
+
device = payload.get("device")
|
|
165
|
+
return await _run_adb("shell", f"input tap {x} {y}", device=device)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def handle_swipe(payload: dict[str, Any]) -> dict[str, Any]:
|
|
169
|
+
"""Swipe between coordinates."""
|
|
170
|
+
x1, y1 = payload.get("x1", 0), payload.get("y1", 0)
|
|
171
|
+
x2, y2 = payload.get("x2", 0), payload.get("y2", 0)
|
|
172
|
+
duration = payload.get("duration", 300)
|
|
173
|
+
device = payload.get("device")
|
|
174
|
+
return await _run_adb(
|
|
175
|
+
"shell", f"input swipe {x1} {y1} {x2} {y2} {duration}",
|
|
176
|
+
device=device,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def handle_type_text(payload: dict[str, Any]) -> dict[str, Any]:
|
|
181
|
+
"""Type text on the device."""
|
|
182
|
+
text = payload.get("text", "")
|
|
183
|
+
device = payload.get("device")
|
|
184
|
+
# Escape spaces for ADB input
|
|
185
|
+
escaped = text.replace(" ", "%s")
|
|
186
|
+
return await _run_adb("shell", f"input text '{escaped}'", device=device)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def handle_press_key(payload: dict[str, Any]) -> dict[str, Any]:
|
|
190
|
+
"""Press a key event (e.g., KEYCODE_HOME)."""
|
|
191
|
+
keycode = payload.get("keycode", "KEYCODE_HOME")
|
|
192
|
+
device = payload.get("device")
|
|
193
|
+
return await _run_adb("shell", f"input keyevent {keycode}", device=device)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Command dispatcher
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
COMMAND_HANDLERS: dict[str, Any] = {
|
|
201
|
+
"list_devices": handle_list_devices,
|
|
202
|
+
"adb_shell": handle_adb_shell,
|
|
203
|
+
"adb_install": handle_adb_install,
|
|
204
|
+
"launch_emulator": handle_launch_emulator,
|
|
205
|
+
"screenshot": handle_screenshot,
|
|
206
|
+
"tap": handle_tap,
|
|
207
|
+
"swipe": handle_swipe,
|
|
208
|
+
"type_text": handle_type_text,
|
|
209
|
+
"press_key": handle_press_key,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def dispatch_command(msg: dict[str, Any]) -> dict[str, Any]:
|
|
214
|
+
"""Dispatch an incoming command message to the appropriate handler.
|
|
215
|
+
|
|
216
|
+
Expected message format:
|
|
217
|
+
{"type": "command", "command": "<name>", "request_id": "...", ...payload}
|
|
218
|
+
|
|
219
|
+
Returns a response dict with the same request_id.
|
|
220
|
+
"""
|
|
221
|
+
command = msg.get("command", "")
|
|
222
|
+
request_id = msg.get("request_id", "")
|
|
223
|
+
handler = COMMAND_HANDLERS.get(command)
|
|
224
|
+
|
|
225
|
+
if not handler:
|
|
226
|
+
return {
|
|
227
|
+
"type": "response",
|
|
228
|
+
"request_id": request_id,
|
|
229
|
+
"error": f"Unknown command: {command}",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
result = await handler(msg)
|
|
234
|
+
return {"type": "response", "request_id": request_id, **result}
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.exception("Command '%s' failed", command)
|
|
237
|
+
return {
|
|
238
|
+
"type": "response",
|
|
239
|
+
"request_id": request_id,
|
|
240
|
+
"error": str(e),
|
|
241
|
+
}
|
package/main.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TA Studio MCP — Entry point for the thin local relay.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m ta-studio-mcp
|
|
6
|
+
# or
|
|
7
|
+
npx ta-studio-mcp@latest
|
|
8
|
+
|
|
9
|
+
Connects outbound to the TA Studio server via WebSocket. Receives emulator
|
|
10
|
+
commands, executes them locally via ADB, and streams results back.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .auth import build_auth_headers
|
|
21
|
+
from .ws_client import TAWebSocketClient, DEFAULT_SERVER_URL
|
|
22
|
+
from .emulator_relay import dispatch_command
|
|
23
|
+
from .stream import FrameStreamer
|
|
24
|
+
|
|
25
|
+
logging.basicConfig(
|
|
26
|
+
level=logging.INFO,
|
|
27
|
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
28
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
29
|
+
)
|
|
30
|
+
logger = logging.getLogger("ta-studio-mcp")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def main() -> None:
|
|
34
|
+
"""Start the thin relay — connect out to TA Studio server."""
|
|
35
|
+
server_url = os.getenv("TA_STUDIO_URL", "").rstrip("/")
|
|
36
|
+
if server_url:
|
|
37
|
+
# Convert HTTP URL to WebSocket URL
|
|
38
|
+
ws_url = server_url.replace("https://", "wss://").replace("http://", "ws://")
|
|
39
|
+
if not ws_url.endswith("/ws/agent-relay"):
|
|
40
|
+
ws_url += "/ws/agent-relay"
|
|
41
|
+
else:
|
|
42
|
+
ws_url = DEFAULT_SERVER_URL
|
|
43
|
+
|
|
44
|
+
logger.info("TA Studio thin relay starting")
|
|
45
|
+
logger.info("Server: %s", ws_url)
|
|
46
|
+
|
|
47
|
+
# Build auth headers
|
|
48
|
+
try:
|
|
49
|
+
auth_headers = build_auth_headers()
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
logger.error(str(e))
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
client = TAWebSocketClient(server_url=ws_url, auth_headers=auth_headers)
|
|
55
|
+
streamer = FrameStreamer(send_fn=client.send)
|
|
56
|
+
|
|
57
|
+
# -- Handle commands from server -----------------------------------------
|
|
58
|
+
|
|
59
|
+
async def on_command(msg: dict) -> None:
|
|
60
|
+
"""Handle command messages from the TA agent."""
|
|
61
|
+
response = await dispatch_command(msg)
|
|
62
|
+
await client.send(response)
|
|
63
|
+
|
|
64
|
+
async def on_stream_start(msg: dict) -> None:
|
|
65
|
+
"""Start frame streaming when server requests it."""
|
|
66
|
+
session_id = msg.get("session_id", "default")
|
|
67
|
+
device = msg.get("device")
|
|
68
|
+
fps = msg.get("fps", 2)
|
|
69
|
+
streamer._device = device
|
|
70
|
+
streamer._fps = min(fps, 10)
|
|
71
|
+
await streamer.start(session_id)
|
|
72
|
+
|
|
73
|
+
async def on_stream_stop(_msg: dict) -> None:
|
|
74
|
+
"""Stop frame streaming."""
|
|
75
|
+
await streamer.stop()
|
|
76
|
+
|
|
77
|
+
async def on_connected(_msg: dict) -> None:
|
|
78
|
+
"""Send identity info on connection."""
|
|
79
|
+
from .emulator_relay import handle_list_devices
|
|
80
|
+
devices = await handle_list_devices({})
|
|
81
|
+
await client.send({
|
|
82
|
+
"type": "relay_ready",
|
|
83
|
+
"version": "1.0.0",
|
|
84
|
+
"devices": devices.get("devices", []),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
client.on("command", on_command)
|
|
88
|
+
client.on("stream_start", on_stream_start)
|
|
89
|
+
client.on("stream_stop", on_stream_stop)
|
|
90
|
+
client.on("connected", on_connected)
|
|
91
|
+
|
|
92
|
+
# Run until interrupted
|
|
93
|
+
try:
|
|
94
|
+
await client.connect()
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
await client.disconnect()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run() -> None:
|
|
100
|
+
"""Synchronous entry point."""
|
|
101
|
+
asyncio.run(main())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
run()
|