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.
Files changed (38) hide show
  1. package/README.md +24 -141
  2. package/__init__.py +14 -0
  3. package/auth.py +53 -0
  4. package/bin/cli.js +69 -0
  5. package/emulator_relay.py +241 -0
  6. package/main.py +105 -0
  7. package/package.json +13 -47
  8. package/stream.py +110 -0
  9. package/ws_client.py +135 -0
  10. package/LICENSE +0 -22
  11. package/dist/index.d.ts +0 -3
  12. package/dist/index.d.ts.map +0 -1
  13. package/dist/index.js +0 -28
  14. package/dist/index.js.map +0 -1
  15. package/dist/knowledge/codebase-map.d.ts +0 -6
  16. package/dist/knowledge/codebase-map.d.ts.map +0 -1
  17. package/dist/knowledge/codebase-map.js +0 -121
  18. package/dist/knowledge/codebase-map.js.map +0 -1
  19. package/dist/knowledge/conventions.d.ts +0 -6
  20. package/dist/knowledge/conventions.d.ts.map +0 -1
  21. package/dist/knowledge/conventions.js +0 -101
  22. package/dist/knowledge/conventions.js.map +0 -1
  23. package/dist/knowledge/known-issues.d.ts +0 -17
  24. package/dist/knowledge/known-issues.d.ts.map +0 -1
  25. package/dist/knowledge/known-issues.js +0 -103
  26. package/dist/knowledge/known-issues.js.map +0 -1
  27. package/dist/knowledge/methodology.d.ts +0 -6
  28. package/dist/knowledge/methodology.d.ts.map +0 -1
  29. package/dist/knowledge/methodology.js +0 -235
  30. package/dist/knowledge/methodology.js.map +0 -1
  31. package/dist/knowledge/workflows.d.ts +0 -7
  32. package/dist/knowledge/workflows.d.ts.map +0 -1
  33. package/dist/knowledge/workflows.js +0 -146
  34. package/dist/knowledge/workflows.js.map +0 -1
  35. package/dist/tools/register-all.d.ts +0 -6
  36. package/dist/tools/register-all.d.ts.map +0 -1
  37. package/dist/tools/register-all.js +0 -104
  38. package/dist/tools/register-all.js.map +0 -1
package/README.md CHANGED
@@ -1,144 +1,27 @@
1
- # ta-studio-mcp 🚀
2
-
3
- **The definitive domain knowledge layer for AI agents building mobile test automation at TA Studio.**
4
-
5
- AI agents often struggle with project-specific context, unique navigation patterns, and "tribal knowledge" about past bugs. `ta-studio-mcp` solves this by giving your agent (Claude, Cursor, Windsurf) structured, programmatic access to the team's methodologies, codebase maps, and verified bug fixes.
6
-
7
- ---
8
-
9
- ## ⚡ Quick Start
10
-
11
- ```bash
12
- npx ta-studio-mcp
13
- ```
14
-
15
- ---
16
-
17
- ## 🎨 Figma Flow Analysis Pipeline
18
-
19
- The cornerstone of TA Studio's design-to-test workflow is a sophisticated 3-phase analysis pipeline that converts complex Figma documents into actionable test plans.
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
- ### Cursor / Windsurf
139
- Add `npx -y ta-studio-mcp@latest` as a command-type MCP server.
140
-
141
- ---
23
+ ## Requirements
142
24
 
143
- ## 📜 License
144
- MIT © 2026 TA Studios.
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()