ta-studio-mcp 1.2.4 → 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 -95
- 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 +14 -48
- 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 -180
- 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,98 +1,27 @@
|
|
|
1
|
-
# ta-studio-mcp
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
}
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## 🧠 Expert Knowledge & Deep Technical Lore
|
|
25
|
-
|
|
26
|
-
This section documents the state-of-the-art implementations used by the TA Studio team.
|
|
27
|
-
|
|
28
|
-
### 1. Set-of-Mark (SoM) Screenshot Annotation
|
|
29
|
-
Based on OmniParser's SoM approach, we use color-coded, type-aware bounding boxes to provide visual anchors.
|
|
30
|
-
- **Type-Aware Palette**: 9 distinct colors (e.g., **Dodger Blue** for buttons, **Orange** for inputs, **Purple** for toggles).
|
|
31
|
-
- **PIL Threading**: `asyncio.to_thread(_draw_bounding_boxes_threaded)` for non-blocking UI drawing.
|
|
32
|
-
- **TOON Optimization**: **Token Optimized Object Notation** reduces prompt tokens by 40% by stripping redundant XML.
|
|
33
|
-
- **Scaling Correction**: Screenshots are compressed to ~45% resolution. We apply `native_coord * (img_width / native_width)` for pixel-perfect alignment.
|
|
34
|
-
|
|
35
|
-
### 2. Deep Subagent Handoff Protocol
|
|
36
|
-
Our "Deep Agent Pattern" orchestrates specialized specialists via a strict chain of custody:
|
|
37
|
-
1. **Perceptor** (`Screen Classifier`): Returns structured state and **TOON** elements.
|
|
38
|
-
2. **Planner** (`Device Agent`): Proposes action based on the identified UI state.
|
|
39
|
-
3. **Guardrail** (`Action Verifier`): Applies **Boolean Verification** (Safe/Relevant/Executable).
|
|
40
|
-
4. **Actor** (`Mobile MCP`): Executes the approved action on the target device.
|
|
41
|
-
5. **Doctor** (`Failure Diagnosis`): Categorizes failures and suggests recovery (Jitter/Wait/Backtrack).
|
|
42
|
-
|
|
43
|
-
### 3. Boolean Verification vs. Numerical Scoring
|
|
44
|
-
We reject "0.85 confidence" scores. Every action must pass three binary checks:
|
|
45
|
-
- **is_safe**: Does this action cause data loss or unauthorized access? (YES/NO)
|
|
46
|
-
- **is_relevant**: Does this move the needle on the task goal? (YES/NO)
|
|
47
|
-
- **is_executable**: Is the target realistically reachable on the current screen? (YES/NO)
|
|
48
|
-
- **Logic**: Action executes ONLY if ALL checks are YES. Reject and propose an `alternative_action` otherwise.
|
|
49
|
-
|
|
50
|
-
### 4. Real-Time HUD & Parallel Execution
|
|
51
|
-
- **Observation Pipeline**: Achieve `<200ms` lag via `on_step` async callbacks that emit SSE events to the frontend.
|
|
52
|
-
- **Concurrency Control**: `asyncio.Semaphore` and per-simulation `asyncio.Lock` manage multiple parallel device streams without resource collision.
|
|
53
|
-
- **Retention**: Automated 24h age or 100 total simulations cleanup before auto-purge.
|
|
54
|
-
|
|
55
|
-
### 5. Model Tiering (Jan 2026 Standard)
|
|
56
|
-
- **Thinking Tier (GPT-5.2)**: High-level orchestration (Coordinator) and complex visual reasoning. reasoning effort: `high`.
|
|
57
|
-
- **Core Tier (GPT-5-mini)**: Specialized agents (Classifier, Verifier, Diagnosis). *Never use nano for classification.*
|
|
58
|
-
- **Utility Tier (GPT-5-nano)**: MCP tool call formatting and data distillation.
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## 🐞 Critical Bug Fixes (Implementation Level)
|
|
63
|
-
|
|
64
|
-
| Severity | Issue | Root Cause & Expert Fix |
|
|
65
|
-
|----------|-------|-------------------------|
|
|
66
|
-
| **CRITICAL** | Bbox Misalignment | **RC**: 45% Scaling Delta. **Fix**: Apply `img_width / native_width` factor. |
|
|
67
|
-
| **CRITICAL** | Async to_thread | **RC**: CORO vs CALL mismatch. **Fix**: Remove `async` from functions passed to `asyncio.to_thread`. |
|
|
68
|
-
| **CRITICAL** | Race Condition | **RC**: Parallel session state collision. **Fix**: Set `parallel_tool_calls=False`. |
|
|
69
|
-
| **HIGH** | Simulation Leak | **RC**: Memory persistence. **Fix**: 24h/100-run auto-purge with `asyncio.Lock`. |
|
|
70
|
-
| **HIGH** | Mobile MCP Bug | **RC**: Offline device fail (v0.0.36). **Fix**: Full ADB bridge fallback (screencap/uiautomator). |
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## 🔄 Core Workflows
|
|
75
|
-
|
|
76
|
-
### The Ralph Loop (Closed-Loop Verification)
|
|
77
|
-
1. **CODE** → Implement feature or fix.
|
|
78
|
-
2. **LINT** → `mypy` / `eslint` verification.
|
|
79
|
-
3. **UNIT TEST** → Specific module verification.
|
|
80
|
-
4. **CHECK ASYNC** → Confirm `to_thread` safety.
|
|
81
|
-
5. **VERIFY HUD** → Watch the emulator stream while agent runs autonomously.
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
## 📦 Installation & Setup
|
|
86
|
-
|
|
87
|
-
### Claude Desktop
|
|
88
|
-
```bash
|
|
89
|
-
claude mcp add ta-studio -- npx -y ta-studio-mcp@latest
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### Cursor / Windsurf
|
|
93
|
-
Add `npx -y ta-studio-mcp@latest` as a command-type MCP server.
|
|
94
|
-
|
|
95
|
-
---
|
|
23
|
+
## Requirements
|
|
96
24
|
|
|
97
|
-
|
|
98
|
-
|
|
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()
|
package/package.json
CHANGED
|
@@ -1,59 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ta-studio-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "TA Studio MCP —
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "TA Studio MCP — thin local relay for AI-driven mobile QA",
|
|
6
5
|
"bin": {
|
|
7
|
-
"ta-studio-mcp": "
|
|
6
|
+
"ta-studio-mcp": "bin/cli.js"
|
|
8
7
|
},
|
|
9
|
-
"main": "./dist/index.js",
|
|
10
|
-
"files": [
|
|
11
|
-
"dist",
|
|
12
|
-
"README.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc",
|
|
17
|
-
"dev": "tsc --watch",
|
|
18
|
-
"start": "node dist/index.js",
|
|
19
|
-
"prepublishOnly": "npm run build"
|
|
20
|
-
},
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/TA-Studios-AI-Avengers/ta-agent-examples.git",
|
|
24
|
-
"directory": "packages/ta-studio-mcp"
|
|
25
|
-
},
|
|
26
|
-
"homepage": "https://github.com/TA-Studios-AI-Avengers/ta-agent-examples/tree/main/packages/ta-studio-mcp#readme",
|
|
27
|
-
"bugs": {
|
|
28
|
-
"url": "https://github.com/TA-Studios-AI-Avengers/ta-agent-examples/issues"
|
|
29
|
-
},
|
|
30
|
-
"author": "TA Studios",
|
|
31
8
|
"keywords": [
|
|
32
9
|
"mcp",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
10
|
+
"testing",
|
|
11
|
+
"qa",
|
|
35
12
|
"android",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"ta-studio",
|
|
39
|
-
"claude",
|
|
40
|
-
"openai",
|
|
41
|
-
"agentic",
|
|
42
|
-
"oavr",
|
|
43
|
-
"som-annotation",
|
|
44
|
-
"device-testing",
|
|
45
|
-
"screenshot-annotation"
|
|
13
|
+
"emulator",
|
|
14
|
+
"claude-code"
|
|
46
15
|
],
|
|
47
16
|
"license": "MIT",
|
|
48
|
-
"dependencies": {
|
|
49
|
-
"@modelcontextprotocol/sdk": "^1.21.1",
|
|
50
|
-
"zod": "^3.23.8"
|
|
51
|
-
},
|
|
52
|
-
"devDependencies": {
|
|
53
|
-
"@types/node": "^22.0.0",
|
|
54
|
-
"typescript": "^5.5.4"
|
|
55
|
-
},
|
|
56
17
|
"engines": {
|
|
57
18
|
"node": ">=18"
|
|
58
|
-
}
|
|
59
|
-
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"*.py",
|
|
23
|
+
"README.md"
|
|
24
|
+
]
|
|
25
|
+
}
|