minitap-mcp 0.9.1__py3-none-any.whl → 0.9.2__py3-none-any.whl
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.
- minitap/mcp/core/agents/compare_screenshots/agent.py +3 -11
- minitap/mcp/core/config.py +5 -5
- minitap/mcp/core/sdk_agent.py +0 -6
- minitap/mcp/main.py +15 -68
- minitap/mcp/server/middleware.py +6 -2
- minitap/mcp/tools/execute_mobile_command.py +119 -41
- minitap/mcp/tools/take_screenshot.py +2 -18
- minitap/mcp/tools/upload_screenshot.py +8 -21
- {minitap_mcp-0.9.1.dist-info → minitap_mcp-0.9.2.dist-info}/METADATA +2 -2
- {minitap_mcp-0.9.1.dist-info → minitap_mcp-0.9.2.dist-info}/RECORD +12 -14
- {minitap_mcp-0.9.1.dist-info → minitap_mcp-0.9.2.dist-info}/WHEEL +1 -1
- minitap/mcp/core/cloud_apk.py +0 -117
- minitap/mcp/server/cloud_mobile.py +0 -492
- {minitap_mcp-0.9.1.dist-info → minitap_mcp-0.9.2.dist-info}/entry_points.txt +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import base64
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
from uuid import uuid4
|
|
5
4
|
|
|
@@ -10,7 +9,6 @@ from pydantic import BaseModel
|
|
|
10
9
|
from minitap.mcp.core.device import capture_screenshot, find_mobile_device
|
|
11
10
|
from minitap.mcp.core.llm import get_minitap_llm
|
|
12
11
|
from minitap.mcp.core.utils.images import get_screenshot_message_for_llm
|
|
13
|
-
from minitap.mcp.server.cloud_mobile import get_cloud_mobile_id, get_cloud_screenshot
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
class CompareScreenshotsOutput(BaseModel):
|
|
@@ -25,7 +23,7 @@ async def compare_screenshots(
|
|
|
25
23
|
"""
|
|
26
24
|
Compare screenshots and return the comparison text along with both screenshots.
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
Captures screenshot from local device and compares with expected screenshot.
|
|
29
27
|
|
|
30
28
|
Returns:
|
|
31
29
|
CompareScreenshotsOutput
|
|
@@ -34,14 +32,8 @@ async def compare_screenshots(
|
|
|
34
32
|
Path(__file__).parent.joinpath("prompt.md").read_text(encoding="utf-8")
|
|
35
33
|
).render()
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if cloud_mobile_id:
|
|
40
|
-
screenshot_bytes = await get_cloud_screenshot(cloud_mobile_id)
|
|
41
|
-
current_screenshot = base64.b64encode(screenshot_bytes).decode("utf-8")
|
|
42
|
-
else:
|
|
43
|
-
device = find_mobile_device()
|
|
44
|
-
current_screenshot = capture_screenshot(device)
|
|
35
|
+
device = find_mobile_device()
|
|
36
|
+
current_screenshot = capture_screenshot(device)
|
|
45
37
|
|
|
46
38
|
messages: list[BaseMessage] = [
|
|
47
39
|
SystemMessage(content=system_message),
|
minitap/mcp/core/config.py
CHANGED
|
@@ -62,11 +62,11 @@ class MCPSettings(BaseSettings):
|
|
|
62
62
|
MCP_SERVER_HOST: str = Field(default="0.0.0.0")
|
|
63
63
|
MCP_SERVER_PORT: int = Field(default=8000)
|
|
64
64
|
|
|
65
|
-
#
|
|
66
|
-
# When
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
65
|
+
# Local device configuration
|
|
66
|
+
# When False, disables the local device health poller.
|
|
67
|
+
# Useful when running the MCP server without a local device attached
|
|
68
|
+
# (e.g., only using cloud_platform parameter in execute_mobile_command).
|
|
69
|
+
ENABLE_LOCAL_DEVICE: bool = Field(default=True)
|
|
70
70
|
|
|
71
71
|
# Trajectory GIF download configuration
|
|
72
72
|
# When set, downloads the trajectory GIF after task execution to the specified folder.
|
minitap/mcp/core/sdk_agent.py
CHANGED
|
@@ -3,8 +3,6 @@ import os
|
|
|
3
3
|
from minitap.mobile_use.sdk import Agent
|
|
4
4
|
from minitap.mobile_use.sdk.builders import Builders
|
|
5
5
|
|
|
6
|
-
from minitap.mcp.core.config import settings
|
|
7
|
-
|
|
8
6
|
# Lazy-initialized singleton agent
|
|
9
7
|
_agent: Agent | None = None
|
|
10
8
|
|
|
@@ -26,10 +24,6 @@ def get_mobile_use_agent() -> Agent:
|
|
|
26
24
|
_, host, port = parts
|
|
27
25
|
config = config.with_adb_server(host=host, port=int(port))
|
|
28
26
|
|
|
29
|
-
# Add cloud mobile configuration if set
|
|
30
|
-
if settings.CLOUD_MOBILE_NAME:
|
|
31
|
-
config = config.for_cloud_mobile(cloud_mobile_id_or_ref=settings.CLOUD_MOBILE_NAME)
|
|
32
|
-
|
|
33
27
|
_agent = Agent(config=config.build())
|
|
34
28
|
|
|
35
29
|
return _agent
|
minitap/mcp/main.py
CHANGED
|
@@ -35,7 +35,6 @@ from minitap.mcp.core.logging_config import (
|
|
|
35
35
|
configure_logging, # noqa: E402
|
|
36
36
|
get_logger,
|
|
37
37
|
)
|
|
38
|
-
from minitap.mcp.server.cloud_mobile import CloudMobileService
|
|
39
38
|
from minitap.mcp.server.middleware import LocalDeviceHealthMiddleware
|
|
40
39
|
from minitap.mcp.server.poller import device_health_poller
|
|
41
40
|
from minitap.mobile_use.config import settings as sdk_settings
|
|
@@ -54,13 +53,7 @@ def main() -> None:
|
|
|
54
53
|
default=None,
|
|
55
54
|
help="Minitap API key for authentication",
|
|
56
55
|
)
|
|
57
|
-
|
|
58
|
-
"--cloud-mobile-name",
|
|
59
|
-
type=str,
|
|
60
|
-
required=False,
|
|
61
|
-
default=None,
|
|
62
|
-
help="Name of the cloud mobile device to connect to (enables cloud mode)",
|
|
63
|
-
)
|
|
56
|
+
|
|
64
57
|
parser.add_argument("--llm-profile", type=str, required=False, default=None)
|
|
65
58
|
parser.add_argument(
|
|
66
59
|
"--server",
|
|
@@ -82,11 +75,6 @@ def main() -> None:
|
|
|
82
75
|
settings.__init__()
|
|
83
76
|
sdk_settings.__init__()
|
|
84
77
|
|
|
85
|
-
if args.cloud_mobile_name:
|
|
86
|
-
os.environ["CLOUD_MOBILE_NAME"] = args.cloud_mobile_name
|
|
87
|
-
settings.__init__()
|
|
88
|
-
sdk_settings.__init__()
|
|
89
|
-
|
|
90
78
|
if args.llm_profile:
|
|
91
79
|
os.environ["MINITAP_LLM_PROFILE_NAME"] = args.llm_profile
|
|
92
80
|
settings.__init__()
|
|
@@ -123,7 +111,6 @@ class MCPLifespanContext:
|
|
|
123
111
|
Stores references to services that need cleanup on shutdown.
|
|
124
112
|
"""
|
|
125
113
|
|
|
126
|
-
cloud_mobile_service: CloudMobileService | None = None
|
|
127
114
|
local_poller_stop_event: threading.Event | None = None
|
|
128
115
|
local_poller_thread: threading.Thread | None = None
|
|
129
116
|
remote_mcp_proxy: FastMCP | None = None
|
|
@@ -133,67 +120,30 @@ class MCPLifespanContext:
|
|
|
133
120
|
async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
|
|
134
121
|
"""Lifespan context manager for MCP server.
|
|
135
122
|
|
|
136
|
-
Handles startup/shutdown for
|
|
123
|
+
Handles startup/shutdown for local and cloud-only modes.
|
|
137
124
|
|
|
138
|
-
|
|
139
|
-
- Connects to cloud mobile on startup
|
|
140
|
-
- Maintains keep-alive polling
|
|
141
|
-
- Disconnects on shutdown (critical for billing!)
|
|
142
|
-
|
|
143
|
-
Local mode (CLOUD_MOBILE_NAME not set):
|
|
125
|
+
Local mode (ENABLE_LOCAL_DEVICE=True, default):
|
|
144
126
|
- Starts device health poller
|
|
145
127
|
- Monitors local device connection
|
|
146
128
|
|
|
129
|
+
Cloud-only mode (ENABLE_LOCAL_DEVICE=False):
|
|
130
|
+
- No local device polling
|
|
131
|
+
- Use cloud_platform parameter in execute_mobile_command to run tasks
|
|
132
|
+
|
|
147
133
|
Args:
|
|
148
134
|
server: The FastMCP server instance.
|
|
149
135
|
|
|
150
136
|
Yields:
|
|
151
137
|
MCPLifespanContext with references to running services.
|
|
152
|
-
|
|
153
|
-
Raises:
|
|
154
|
-
RuntimeError: If cloud mobile connection fails
|
|
155
|
-
(crashes MCP to prevent false "connected" state).
|
|
156
138
|
"""
|
|
157
139
|
from minitap.mcp.core.sdk_agent import get_mobile_use_agent # noqa: E402
|
|
158
140
|
|
|
159
141
|
context = MCPLifespanContext()
|
|
160
142
|
api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
|
|
161
143
|
|
|
162
|
-
|
|
163
|
-
if settings.CLOUD_MOBILE_NAME:
|
|
164
|
-
# ==================== CLOUD MODE ====================
|
|
165
|
-
logger.info(f"Starting MCP in CLOUD mode with mobile: {settings.CLOUD_MOBILE_NAME}")
|
|
166
|
-
|
|
167
|
-
if not api_key:
|
|
168
|
-
logger.error("MINITAP_API_KEY is required")
|
|
169
|
-
raise RuntimeError(
|
|
170
|
-
"MINITAP_API_KEY is required when CLOUD_MOBILE_NAME is set. "
|
|
171
|
-
"Please set your API key via --api-key or MINITAP_API_KEY environment variable."
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
# Create and connect cloud mobile service
|
|
175
|
-
cloud_service = CloudMobileService(
|
|
176
|
-
cloud_mobile_name=settings.CLOUD_MOBILE_NAME,
|
|
177
|
-
api_key=api_key,
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
try:
|
|
181
|
-
await cloud_service.connect()
|
|
182
|
-
except Exception as e:
|
|
183
|
-
# CRITICAL: If cloud mobile not found, crash the MCP!
|
|
184
|
-
# This prevents the IDE from showing the server as "connected"
|
|
185
|
-
# when it actually can't do anything useful.
|
|
186
|
-
logger.error(f"Failed to connect to cloud mobile: {e}")
|
|
187
|
-
raise RuntimeError(
|
|
188
|
-
f"Cloud mobile connection failed. The MCP server cannot start.\n{e}"
|
|
189
|
-
) from e
|
|
190
|
-
|
|
191
|
-
context.cloud_mobile_service = cloud_service
|
|
192
|
-
logger.info("Cloud mobile connected, MCP server ready")
|
|
193
|
-
|
|
194
|
-
else:
|
|
144
|
+
if settings.ENABLE_LOCAL_DEVICE:
|
|
195
145
|
# ==================== LOCAL MODE ====================
|
|
196
|
-
logger.info("Starting MCP in LOCAL mode
|
|
146
|
+
logger.info("Starting MCP in LOCAL mode with device polling")
|
|
197
147
|
|
|
198
148
|
agent = get_mobile_use_agent()
|
|
199
149
|
server.add_middleware(LocalDeviceHealthMiddleware(agent))
|
|
@@ -210,6 +160,12 @@ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
|
|
|
210
160
|
context.local_poller_stop_event = stop_event
|
|
211
161
|
context.local_poller_thread = poller_thread
|
|
212
162
|
logger.info("Device health poller started")
|
|
163
|
+
else:
|
|
164
|
+
# ==================== CLOUD-ONLY MODE ====================
|
|
165
|
+
logger.info(
|
|
166
|
+
"Starting MCP without local device polling (ENABLE_LOCAL_DEVICE=False). "
|
|
167
|
+
"Use cloud_platform parameter in execute_mobile_command to run tasks."
|
|
168
|
+
)
|
|
213
169
|
|
|
214
170
|
# ==================== REMOTE MCP PROXY ====================
|
|
215
171
|
# Mount remote MCP proxy if configured (works in both cloud and local modes)
|
|
@@ -257,15 +213,6 @@ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
|
|
|
257
213
|
# ==================== SHUTDOWN ====================
|
|
258
214
|
logger.info("MCP server shutting down, cleaning up resources...")
|
|
259
215
|
|
|
260
|
-
if context.cloud_mobile_service:
|
|
261
|
-
# CRITICAL: Stop cloud mobile connection to stop billing!
|
|
262
|
-
logger.info("Disconnecting cloud mobile (stopping billing)...")
|
|
263
|
-
try:
|
|
264
|
-
await context.cloud_mobile_service.disconnect()
|
|
265
|
-
logger.info("Cloud mobile disconnected successfully")
|
|
266
|
-
except Exception as e:
|
|
267
|
-
logger.error(f"Error disconnecting cloud mobile: {e}")
|
|
268
|
-
|
|
269
216
|
if context.local_poller_stop_event and context.local_poller_thread:
|
|
270
217
|
# Stop local device health poller
|
|
271
218
|
logger.info("Stopping device health poller...")
|
minitap/mcp/server/middleware.py
CHANGED
|
@@ -6,14 +6,18 @@ from minitap.mobile_use.sdk import Agent
|
|
|
6
6
|
class LocalDeviceHealthMiddleware(Middleware):
|
|
7
7
|
"""Middleware that checks local device health before tool calls.
|
|
8
8
|
|
|
9
|
-
Only used in local mode (when
|
|
10
|
-
For cloud mode, device health is managed by the cloud service.
|
|
9
|
+
Only used in local mode (when ENABLE_LOCAL_DEVICE is True).
|
|
11
10
|
"""
|
|
12
11
|
|
|
13
12
|
def __init__(self, agent: Agent):
|
|
14
13
|
self.agent = agent
|
|
15
14
|
|
|
16
15
|
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
16
|
+
# Skip agent check if platform is set (Limrun mode creates its own agent)
|
|
17
|
+
tool_args = getattr(context.message, "arguments", None) or {}
|
|
18
|
+
if tool_args.get("cloud_platform"):
|
|
19
|
+
return await call_next(context)
|
|
20
|
+
|
|
17
21
|
if not self.agent._initialized:
|
|
18
22
|
raise ToolError(
|
|
19
23
|
"Agent not initialized.\nMake sure a mobile device is connected and try again."
|
|
@@ -7,11 +7,12 @@ from typing import Any
|
|
|
7
7
|
from fastmcp.exceptions import ToolError
|
|
8
8
|
from fastmcp.tools.tool import ToolResult
|
|
9
9
|
from mcp.types import TextContent
|
|
10
|
-
from minitap.mobile_use.sdk
|
|
10
|
+
from minitap.mobile_use.sdk import Agent
|
|
11
|
+
from minitap.mobile_use.sdk.builders import Builders
|
|
12
|
+
from minitap.mobile_use.sdk.types import LimrunPlatform, ManualTaskConfig
|
|
11
13
|
from minitap.mobile_use.sdk.types.task import PlatformTaskRequest
|
|
12
14
|
from pydantic import Field
|
|
13
15
|
|
|
14
|
-
from minitap.mcp.core.cloud_apk import install_apk_on_cloud_mobile, upload_apk_to_cloud_mobile
|
|
15
16
|
from minitap.mcp.core.config import settings
|
|
16
17
|
from minitap.mcp.core.decorators import handle_tool_errors
|
|
17
18
|
from minitap.mcp.core.logging_config import get_logger
|
|
@@ -19,7 +20,6 @@ from minitap.mcp.core.sdk_agent import get_mobile_use_agent
|
|
|
19
20
|
from minitap.mcp.core.storage import StorageDownloadError, download_trajectory_gif
|
|
20
21
|
from minitap.mcp.core.task_runs import TaskRunsError, get_latest_task_run_id
|
|
21
22
|
from minitap.mcp.main import mcp
|
|
22
|
-
from minitap.mcp.server.cloud_mobile import check_cloud_mobile_status
|
|
23
23
|
|
|
24
24
|
logger = get_logger(__name__)
|
|
25
25
|
|
|
@@ -39,33 +39,36 @@ def _serialize_result(result: Any) -> Any:
|
|
|
39
39
|
name="execute_mobile_command",
|
|
40
40
|
description="""
|
|
41
41
|
Execute a natural language command on a mobile device using the Minitap SDK.
|
|
42
|
-
This tool allows you to control
|
|
42
|
+
This tool allows you to control Android or iOS devices using natural language.
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
- "Open the settings app and tell me the battery level"
|
|
46
|
-
- "Find the first 3 unread emails in Gmail"
|
|
47
|
-
- "Take a screenshot and save it"
|
|
44
|
+
Set cloud_platform to run on a cloud device (recommended for most use cases).
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
- The APK will be uploaded to cloud storage and installed on the device
|
|
53
|
-
- Requires MINITAP_API_KEY environment variable
|
|
54
|
-
- Must provide locked_app_package when using apk_path
|
|
46
|
+
Examples:
|
|
47
|
+
- execute_mobile_command(cloud_platform="android", goal="Open Settings and check battery level")
|
|
48
|
+
- execute_mobile_command(cloud_platform="ios", goal="Open Safari and search for weather")
|
|
55
49
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
50
|
+
App Deployment:
|
|
51
|
+
You can deploy and test apps by providing the path to a locally built app:
|
|
52
|
+
- Set app_path to the path of your .apk file (Android) or .app folder (iOS)
|
|
53
|
+
- The app will be automatically uploaded and installed on the device
|
|
54
|
+
- Must provide locked_app_package (package name or bundle ID) when using app_path
|
|
62
55
|
|
|
63
|
-
|
|
56
|
+
Examples with app deployment:
|
|
57
|
+
- execute_mobile_command(cloud_platform="android", goal="Test login flow",
|
|
58
|
+
app_path="/path/to/app-debug.apk", locked_app_package="com.example.myapp")
|
|
59
|
+
- execute_mobile_command(cloud_platform="ios", goal="Verify onboarding screens",
|
|
60
|
+
app_path="/path/to/MyApp.app", locked_app_package="com.example.myapp")
|
|
64
61
|
""",
|
|
65
62
|
)
|
|
66
63
|
@handle_tool_errors
|
|
67
64
|
async def execute_mobile_command(
|
|
68
65
|
goal: str = Field(description="High-level goal describing the action to perform."),
|
|
66
|
+
cloud_platform: LimrunPlatform | None = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
description="Cloud platform to run on: 'android' or 'ios'. "
|
|
69
|
+
"When set, a cloud device is automatically provisioned for this task "
|
|
70
|
+
"and cleaned up afterwards.",
|
|
71
|
+
),
|
|
69
72
|
output_description: str | None = Field(
|
|
70
73
|
default=None,
|
|
71
74
|
description="Optional description of the expected output format. "
|
|
@@ -76,33 +79,33 @@ async def execute_mobile_command(
|
|
|
76
79
|
default=None,
|
|
77
80
|
description="Optional package name of the app to lock the device to. "
|
|
78
81
|
"Will launch the app if not already running, and keep it in foreground "
|
|
79
|
-
"until the task is completed. REQUIRED when using
|
|
82
|
+
"until the task is completed. REQUIRED when using app_path.",
|
|
80
83
|
),
|
|
81
|
-
|
|
84
|
+
app_path: str | None = Field(
|
|
82
85
|
default=None,
|
|
83
|
-
description="Path to local
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"Requires MINITAP_API_KEY to be configured. ",
|
|
86
|
+
description="Path to local .apk file (Android) or .app folder (iOS) to deploy. "
|
|
87
|
+
"The app will be automatically uploaded and installed on the device. "
|
|
88
|
+
"Must provide locked_app_package when using app_path.",
|
|
87
89
|
),
|
|
88
90
|
) -> str | dict[str, Any] | ToolResult:
|
|
89
91
|
"""Run a manual task on a mobile device via the Minitap platform."""
|
|
90
92
|
try:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Step 1: Upload APK via Platform storage API
|
|
102
|
-
filename = await upload_apk_to_cloud_mobile(apk_path=apk_path)
|
|
93
|
+
# Cloud platform mode: provision a cloud device on-demand
|
|
94
|
+
if cloud_platform:
|
|
95
|
+
return await _execute_with_cloud_platform(
|
|
96
|
+
cloud_platform=cloud_platform,
|
|
97
|
+
goal=goal,
|
|
98
|
+
output_description=output_description,
|
|
99
|
+
locked_app_package=locked_app_package,
|
|
100
|
+
app_path=app_path,
|
|
101
|
+
)
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
# Local device mode
|
|
104
|
+
if app_path:
|
|
105
|
+
raise ToolError(
|
|
106
|
+
"app_path parameter requires cloud_platform to be set. "
|
|
107
|
+
"App deployment is only supported in cloud platform mode."
|
|
108
|
+
)
|
|
106
109
|
|
|
107
110
|
request = PlatformTaskRequest(
|
|
108
111
|
task=ManualTaskConfig(
|
|
@@ -146,6 +149,81 @@ async def execute_mobile_command(
|
|
|
146
149
|
raise ToolError(str(e))
|
|
147
150
|
|
|
148
151
|
|
|
152
|
+
async def _execute_with_cloud_platform(
|
|
153
|
+
cloud_platform: LimrunPlatform,
|
|
154
|
+
goal: str,
|
|
155
|
+
output_description: str | None,
|
|
156
|
+
locked_app_package: str | None,
|
|
157
|
+
app_path: str | None = None,
|
|
158
|
+
) -> str | dict[str, Any] | ToolResult:
|
|
159
|
+
"""Execute a task using a cloud device.
|
|
160
|
+
|
|
161
|
+
Provisions a cloud device, runs the task, and cleans up afterwards.
|
|
162
|
+
"""
|
|
163
|
+
api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
|
|
164
|
+
if not api_key:
|
|
165
|
+
raise ToolError(
|
|
166
|
+
"MINITAP_API_KEY is required for cloud platform mode. "
|
|
167
|
+
"Please set your API key via --api-key or MINITAP_API_KEY environment variable."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
logger.info(f"Provisioning {cloud_platform.value} cloud device for task execution...")
|
|
171
|
+
|
|
172
|
+
# Create a dedicated agent for this cloud task
|
|
173
|
+
config = Builders.AgentConfig.for_limrun(platform=cloud_platform, api_key=api_key)
|
|
174
|
+
agent = Agent(config=config.build())
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
await agent.init(api_key=api_key)
|
|
178
|
+
logger.info(f"{cloud_platform.value} cloud device ready, executing task...")
|
|
179
|
+
|
|
180
|
+
request = PlatformTaskRequest(
|
|
181
|
+
task=ManualTaskConfig(
|
|
182
|
+
goal=goal,
|
|
183
|
+
output_description=output_description,
|
|
184
|
+
),
|
|
185
|
+
execution_origin="mcp",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Pass app_path to run_task - SDK handles upload and installation
|
|
189
|
+
result = await agent.run_task(
|
|
190
|
+
request=request,
|
|
191
|
+
locked_app_package=locked_app_package,
|
|
192
|
+
app_path=app_path,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
trajectory_gif_path: Path | None = None
|
|
196
|
+
if settings.TRAJECTORY_GIF_DOWNLOAD_FOLDER:
|
|
197
|
+
trajectory_gif_path = await _download_trajectory_gif_if_available()
|
|
198
|
+
|
|
199
|
+
serialized_result = _serialize_result(result)
|
|
200
|
+
|
|
201
|
+
if trajectory_gif_path:
|
|
202
|
+
import json
|
|
203
|
+
|
|
204
|
+
result_text = (
|
|
205
|
+
json.dumps(serialized_result, indent=2)
|
|
206
|
+
if isinstance(serialized_result, dict)
|
|
207
|
+
else str(serialized_result)
|
|
208
|
+
)
|
|
209
|
+
return ToolResult(
|
|
210
|
+
content=[
|
|
211
|
+
TextContent(type="text", text=result_text),
|
|
212
|
+
TextContent(type="text", text=f"Trajectory saved to {trajectory_gif_path}"),
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return serialized_result
|
|
217
|
+
finally:
|
|
218
|
+
# Always clean up the cloud device
|
|
219
|
+
logger.info(f"Cleaning up {cloud_platform.value} cloud device...")
|
|
220
|
+
try:
|
|
221
|
+
await agent.clean()
|
|
222
|
+
logger.info(f"{cloud_platform.value} cloud device cleaned up successfully")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Error cleaning up cloud device: {e}")
|
|
225
|
+
|
|
226
|
+
|
|
149
227
|
async def _download_trajectory_gif_if_available() -> Path | None:
|
|
150
228
|
"""Download the trajectory GIF if available and folder is configured.
|
|
151
229
|
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
"""Simple screenshot capture tool - returns raw base64 image without LLM analysis."""
|
|
2
2
|
|
|
3
|
-
import base64
|
|
4
|
-
|
|
5
3
|
from mcp.types import ImageContent
|
|
6
4
|
from pydantic import Field
|
|
7
5
|
|
|
8
6
|
from minitap.mcp.core.decorators import handle_tool_errors
|
|
9
7
|
from minitap.mcp.core.device import capture_screenshot, find_mobile_device
|
|
10
8
|
from minitap.mcp.main import mcp
|
|
11
|
-
from minitap.mcp.server.cloud_mobile import (
|
|
12
|
-
check_cloud_mobile_status,
|
|
13
|
-
get_cloud_mobile_id,
|
|
14
|
-
get_cloud_screenshot,
|
|
15
|
-
)
|
|
16
9
|
|
|
17
10
|
|
|
18
11
|
@mcp.tool(
|
|
@@ -32,17 +25,8 @@ async def take_screenshot(
|
|
|
32
25
|
),
|
|
33
26
|
) -> list[ImageContent]:
|
|
34
27
|
"""Capture screenshot and return as base64 image content."""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if cloud_mobile_id:
|
|
38
|
-
# Cloud mode: use cloud screenshot API
|
|
39
|
-
await check_cloud_mobile_status(cloud_mobile_id)
|
|
40
|
-
screenshot_bytes = await get_cloud_screenshot(cloud_mobile_id)
|
|
41
|
-
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
|
42
|
-
else:
|
|
43
|
-
# Local mode: capture from local device
|
|
44
|
-
device = find_mobile_device(device_id=device_id)
|
|
45
|
-
screenshot_base64 = capture_screenshot(device)
|
|
28
|
+
device = find_mobile_device(device_id=device_id)
|
|
29
|
+
screenshot_base64 = capture_screenshot(device)
|
|
46
30
|
|
|
47
31
|
return [
|
|
48
32
|
ImageContent(
|
|
@@ -5,8 +5,6 @@ to remote storage, returning a filename that can be used with other tools
|
|
|
5
5
|
like figma_compare_screenshot.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import base64
|
|
9
|
-
|
|
10
8
|
from fastmcp.exceptions import ToolError
|
|
11
9
|
from fastmcp.tools.tool import ToolResult
|
|
12
10
|
|
|
@@ -15,7 +13,6 @@ from minitap.mcp.core.device import capture_screenshot, find_mobile_device
|
|
|
15
13
|
from minitap.mcp.core.logging_config import get_logger
|
|
16
14
|
from minitap.mcp.core.storage import StorageUploadError, upload_screenshot_to_storage
|
|
17
15
|
from minitap.mcp.main import mcp
|
|
18
|
-
from minitap.mcp.server.cloud_mobile import get_cloud_mobile_id, get_cloud_screenshot
|
|
19
16
|
|
|
20
17
|
logger = get_logger(__name__)
|
|
21
18
|
|
|
@@ -26,7 +23,7 @@ logger = get_logger(__name__)
|
|
|
26
23
|
Capture a screenshot from the connected device and upload it to storage.
|
|
27
24
|
|
|
28
25
|
This tool:
|
|
29
|
-
1. Captures a screenshot from the connected
|
|
26
|
+
1. Captures a screenshot from the connected local device
|
|
30
27
|
2. Uploads the screenshot to remote storage
|
|
31
28
|
3. Returns a filename that can be used with other tools
|
|
32
29
|
|
|
@@ -43,23 +40,13 @@ async def upload_screenshot() -> ToolResult:
|
|
|
43
40
|
"""Capture and upload a device screenshot, return the filename."""
|
|
44
41
|
logger.info("Capturing and uploading device screenshot")
|
|
45
42
|
|
|
46
|
-
# Step 1: Capture screenshot from device
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
|
54
|
-
except Exception as e:
|
|
55
|
-
raise ToolError(f"Failed to capture cloud device screenshot: {e}") from e
|
|
56
|
-
else:
|
|
57
|
-
logger.debug("Capturing screenshot from local device")
|
|
58
|
-
try:
|
|
59
|
-
device = find_mobile_device()
|
|
60
|
-
screenshot_base64 = capture_screenshot(device)
|
|
61
|
-
except Exception as e:
|
|
62
|
-
raise ToolError(f"Failed to capture local device screenshot: {e}") from e
|
|
43
|
+
# Step 1: Capture screenshot from local device
|
|
44
|
+
logger.debug("Capturing screenshot from local device")
|
|
45
|
+
try:
|
|
46
|
+
device = find_mobile_device()
|
|
47
|
+
screenshot_base64 = capture_screenshot(device)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise ToolError(f"Failed to capture local device screenshot: {e}") from e
|
|
63
50
|
|
|
64
51
|
logger.info("Screenshot captured from device")
|
|
65
52
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: minitap-mcp
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Model Context Protocol server for controlling Android & iOS devices with natural language
|
|
5
5
|
Author: Pierre-Louis Favreau, Jean-Pierre Lo, Clément Guiguet
|
|
6
6
|
Requires-Dist: fastmcp>=2.12.4
|
|
7
7
|
Requires-Dist: python-dotenv>=1.1.1
|
|
8
8
|
Requires-Dist: pydantic>=2.12.0
|
|
9
9
|
Requires-Dist: pydantic-settings>=2.10.1
|
|
10
|
-
Requires-Dist: minitap-mobile-use>=3.
|
|
10
|
+
Requires-Dist: minitap-mobile-use>=3.5.0
|
|
11
11
|
Requires-Dist: jinja2>=3.1.6
|
|
12
12
|
Requires-Dist: langchain-core>=0.3.75
|
|
13
13
|
Requires-Dist: pillow>=11.1.0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
minitap/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
minitap/mcp/core/agents/compare_screenshots/agent.py,sha256=
|
|
2
|
+
minitap/mcp/core/agents/compare_screenshots/agent.py,sha256=jujG09SmTUlnRbFmqPZuaQnR7KboN348sNzljGWpYqA,2136
|
|
3
3
|
minitap/mcp/core/agents/compare_screenshots/eval/prompts/prompt_1.md,sha256=qAyqOroSJROgrvlbsLCtiwFyBKuIMCQ-720A5cwgwPY,3563
|
|
4
4
|
minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/actual.png,sha256=woKE-aTTdb-9ArqfnV-xKKyit1Fu_hklavVwAaMQ14E,99455
|
|
5
5
|
minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/figma.png,sha256=ghxi1P-ofnmMv5_ASm0Rzo5ll_C8-E6ojQUCHRR33TA,102098
|
|
@@ -7,29 +7,27 @@ minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_
|
|
|
7
7
|
minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/model_params.json,sha256=p93kbUGxLYwc4NAEzPTBDA2XZ1ucsa7yevXZRe6V4Mc,37
|
|
8
8
|
minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/output.md,sha256=Q0_5w9x5WAMqx6-I7KgjlS-tt7HnWKIunP43J7F0W_o,3703
|
|
9
9
|
minitap/mcp/core/agents/compare_screenshots/prompt.md,sha256=qAyqOroSJROgrvlbsLCtiwFyBKuIMCQ-720A5cwgwPY,3563
|
|
10
|
-
minitap/mcp/core/
|
|
11
|
-
minitap/mcp/core/config.py,sha256=dCQMuT-jvGHBxIsvTQoUDOvZ98MTO5iwgCc3_bngX5M,4407
|
|
10
|
+
minitap/mcp/core/config.py,sha256=lFpvKb_lpJRRWl_PF0SoRuK4Qssqy_3Uy2aGBt895nY,4382
|
|
12
11
|
minitap/mcp/core/decorators.py,sha256=ipzR7kbMXacG91f6CliN-nl9unRTtjmANrfueaOXJ2s,3591
|
|
13
12
|
minitap/mcp/core/device.py,sha256=0AU8qGi26axC6toqHrPIzNeDbNDtll0YRwkspHouPmM,8198
|
|
14
13
|
minitap/mcp/core/llm.py,sha256=tI5m5rFDLeMkXE5WExnzYSzHU3nTIEiSC9nAsPzVMaU,1144
|
|
15
14
|
minitap/mcp/core/logging_config.py,sha256=GElnKsOFtk58EMJWPKRGtKW101tnCubNKRFTe0gEh4I,2020
|
|
16
15
|
minitap/mcp/core/models.py,sha256=egLScxPAMo4u5cqY33UKba7z7DsdgqfPW409UAqW1Jg,1942
|
|
17
|
-
minitap/mcp/core/sdk_agent.py,sha256=
|
|
16
|
+
minitap/mcp/core/sdk_agent.py,sha256=NmXMo4ZLAaiJgyRvyyrA-zbCGSrv7HveAxYgyKosbJU,902
|
|
18
17
|
minitap/mcp/core/storage.py,sha256=t9BAEqXH7Nu8p8hgGIk3mO6rxLwviBP0FqvQD4Lz8CQ,13184
|
|
19
18
|
minitap/mcp/core/task_runs.py,sha256=vGv8G-oZcfe_lpMOGiM649u10WNzoU3uWDgYWm1owdQ,3115
|
|
20
19
|
minitap/mcp/core/utils/figma.py,sha256=L5aAHm59mrRYaqrwMJSM24SSdZPu2yVg-wsHTF3L8vk,2310
|
|
21
20
|
minitap/mcp/core/utils/images.py,sha256=3uExpRoh7affIieZx3TLlZTmZCcoxWfx1YpPbwhjiJY,1791
|
|
22
|
-
minitap/mcp/main.py,sha256=
|
|
23
|
-
minitap/mcp/server/
|
|
24
|
-
minitap/mcp/server/middleware.py,sha256=SjPc4pcfPuG0TnaDH7a19DS_HRFPl3bkbovdOLzy_IU,768
|
|
21
|
+
minitap/mcp/main.py,sha256=XCGHpi_afJRec5Bw_WQYZm252jSYPiGVPQ_S7lH9680,9163
|
|
22
|
+
minitap/mcp/server/middleware.py,sha256=-UosvRBlk_DglmTte3Flh-1z0fbb6Cogg6D0T8rMExg,941
|
|
25
23
|
minitap/mcp/server/poller.py,sha256=JsdW6nvj4r3tsn8AaTwXD4H9dVAAau4BhJXHXHit9nA,2528
|
|
26
24
|
minitap/mcp/server/remote_proxy.py,sha256=IM7UfjbJlQRpFD_tdpdck1mFT1QOnlxj5OA1nS4tRhQ,3073
|
|
27
|
-
minitap/mcp/tools/execute_mobile_command.py,sha256=
|
|
25
|
+
minitap/mcp/tools/execute_mobile_command.py,sha256=ouSC5LZQ8Fc-PUGMBYB9TnZ_BKSKwANmdnn-36l5Lx4,9766
|
|
28
26
|
minitap/mcp/tools/read_swift_logs.py,sha256=Wc1XqQWWuNuPEIBioYD2geVd1p9Yq2USik6SX47Fq9A,9285
|
|
29
27
|
minitap/mcp/tools/screen_analyzer.md,sha256=TTO80JQWusbA9cKAZn-9cqhgVHm6F_qJh5w152hG3YM,734
|
|
30
|
-
minitap/mcp/tools/take_screenshot.py,sha256=
|
|
31
|
-
minitap/mcp/tools/upload_screenshot.py,sha256=
|
|
32
|
-
minitap_mcp-0.9.
|
|
33
|
-
minitap_mcp-0.9.
|
|
34
|
-
minitap_mcp-0.9.
|
|
35
|
-
minitap_mcp-0.9.
|
|
28
|
+
minitap/mcp/tools/take_screenshot.py,sha256=aL58qJaiLkBK95Ziagn7RQe713Pbu8E7AcoyK3GLlyw,1196
|
|
29
|
+
minitap/mcp/tools/upload_screenshot.py,sha256=Ue1w8RHNNdGEIJizRdqKrmzeP0kymu1s5Uk3v2Ge-3E,2372
|
|
30
|
+
minitap_mcp-0.9.2.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
31
|
+
minitap_mcp-0.9.2.dist-info/entry_points.txt,sha256=rYVoXm7tSQCqQTtHx4Lovgn1YsjwtEEHfddKrfEVHuY,55
|
|
32
|
+
minitap_mcp-0.9.2.dist-info/METADATA,sha256=AbAyk7cQEZiI6K_u-N_twOLewx-cTHmPwG1S6JGG5hE,10619
|
|
33
|
+
minitap_mcp-0.9.2.dist-info/RECORD,,
|
minitap/mcp/core/cloud_apk.py
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
"""Cloud APK deployment utilities for uploading and installing APKs on cloud mobiles."""
|
|
2
|
-
|
|
3
|
-
import uuid
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import httpx
|
|
7
|
-
|
|
8
|
-
from minitap.mcp.core.config import settings
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
async def upload_apk_to_cloud_mobile(apk_path: str) -> str:
|
|
12
|
-
"""
|
|
13
|
-
Upload an APK file via Platform storage API to user storage bucket.
|
|
14
|
-
|
|
15
|
-
Args:
|
|
16
|
-
apk_path: Path to the APK file
|
|
17
|
-
|
|
18
|
-
Returns:
|
|
19
|
-
Filename to use with install-apk endpoint
|
|
20
|
-
|
|
21
|
-
Raises:
|
|
22
|
-
FileNotFoundError: If APK file doesn't exist
|
|
23
|
-
httpx.HTTPError: If upload fails
|
|
24
|
-
ValueError: If MINITAP_API_KEY or MINITAP_API_BASE_URL is not configured
|
|
25
|
-
"""
|
|
26
|
-
if not settings.MINITAP_API_KEY:
|
|
27
|
-
raise ValueError("MINITAP_API_KEY is not configured")
|
|
28
|
-
if not settings.MINITAP_API_BASE_URL:
|
|
29
|
-
raise ValueError("MINITAP_API_BASE_URL is not configured")
|
|
30
|
-
|
|
31
|
-
apk_file = Path(apk_path)
|
|
32
|
-
if not apk_file.exists():
|
|
33
|
-
raise FileNotFoundError(f"APK file not found: {apk_path}")
|
|
34
|
-
|
|
35
|
-
# Use APP_PACKAGE_NAME env var if set, otherwise generate a random name
|
|
36
|
-
# Preserve the original file extension
|
|
37
|
-
extension = apk_file.suffix # e.g., ".apk"
|
|
38
|
-
if settings.APP_PACKAGE_NAME:
|
|
39
|
-
# Strip any existing extension from APP_PACKAGE_NAME to avoid double extensions
|
|
40
|
-
base_name = Path(settings.APP_PACKAGE_NAME).stem
|
|
41
|
-
filename = f"{base_name}{extension}"
|
|
42
|
-
else:
|
|
43
|
-
filename = f"app_{uuid.uuid4().hex[:6]}{extension}"
|
|
44
|
-
api_key = settings.MINITAP_API_KEY.get_secret_value()
|
|
45
|
-
api_base_url = settings.MINITAP_API_BASE_URL.rstrip("/")
|
|
46
|
-
|
|
47
|
-
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
48
|
-
# Step 1: Get signed upload URL from storage API
|
|
49
|
-
response = await client.get(
|
|
50
|
-
f"{api_base_url}/storage/signed-upload",
|
|
51
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
52
|
-
params={"filenames": filename},
|
|
53
|
-
)
|
|
54
|
-
response.raise_for_status()
|
|
55
|
-
upload_data = response.json()
|
|
56
|
-
|
|
57
|
-
# Extract the signed URL for our file
|
|
58
|
-
signed_urls = upload_data.get("signed_urls", {})
|
|
59
|
-
if filename not in signed_urls:
|
|
60
|
-
raise ValueError(f"No signed URL returned for {filename}")
|
|
61
|
-
|
|
62
|
-
signed_url = signed_urls[filename]
|
|
63
|
-
|
|
64
|
-
# Step 2: Upload APK to signed URL
|
|
65
|
-
with open(apk_file, "rb") as f:
|
|
66
|
-
upload_response = await client.put(
|
|
67
|
-
signed_url,
|
|
68
|
-
content=f.read(),
|
|
69
|
-
headers={"Content-Type": "application/vnd.android.package-archive"},
|
|
70
|
-
)
|
|
71
|
-
upload_response.raise_for_status()
|
|
72
|
-
|
|
73
|
-
# Step 3: Return filename for install-apk call
|
|
74
|
-
return filename
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
async def install_apk_on_cloud_mobile(filename: str) -> None:
|
|
78
|
-
"""
|
|
79
|
-
Install an APK on a cloud mobile device via mobile-manager API.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
filename: Filename returned from upload_apk_to_cloud_mobile
|
|
83
|
-
|
|
84
|
-
Raises:
|
|
85
|
-
httpx.HTTPError: If installation fails
|
|
86
|
-
ValueError: If required config settings are not configured
|
|
87
|
-
"""
|
|
88
|
-
if not settings.MINITAP_API_KEY:
|
|
89
|
-
raise ValueError("MINITAP_API_KEY is not configured")
|
|
90
|
-
if not settings.MINITAP_DAAS_API:
|
|
91
|
-
raise ValueError("MINITAP_DAAS_API is not configured")
|
|
92
|
-
if not settings.CLOUD_MOBILE_NAME:
|
|
93
|
-
raise ValueError("CLOUD_MOBILE_NAME is not configured")
|
|
94
|
-
|
|
95
|
-
api_key = settings.MINITAP_API_KEY.get_secret_value()
|
|
96
|
-
base_url = settings.MINITAP_DAAS_API
|
|
97
|
-
cloud_mobile_name = settings.CLOUD_MOBILE_NAME
|
|
98
|
-
|
|
99
|
-
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
100
|
-
cloud_mobile_response = await client.get(
|
|
101
|
-
f"{base_url}/virtual-mobiles/{cloud_mobile_name}",
|
|
102
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
103
|
-
)
|
|
104
|
-
cloud_mobile_response.raise_for_status()
|
|
105
|
-
response_data = cloud_mobile_response.json()
|
|
106
|
-
cloud_mobile_uuid = response_data.get("id")
|
|
107
|
-
if not cloud_mobile_uuid:
|
|
108
|
-
raise ValueError(f"Cloud mobile '{cloud_mobile_name}' response missing 'id' field")
|
|
109
|
-
response = await client.post(
|
|
110
|
-
f"{base_url}/virtual-mobiles/{cloud_mobile_uuid}/install-apk",
|
|
111
|
-
headers={
|
|
112
|
-
"Authorization": f"Bearer {api_key}",
|
|
113
|
-
"Content-Type": "application/json",
|
|
114
|
-
},
|
|
115
|
-
json={"filename": filename},
|
|
116
|
-
)
|
|
117
|
-
response.raise_for_status()
|
|
@@ -1,492 +0,0 @@
|
|
|
1
|
-
"""Cloud mobile service for managing cloud-hosted mobile devices.
|
|
2
|
-
|
|
3
|
-
This module handles:
|
|
4
|
-
- Connecting to cloud mobiles on the Minitap platform via HTTP API
|
|
5
|
-
- Keep-alive polling to maintain the connection (prevents idle shutdown)
|
|
6
|
-
- Proper cleanup when the MCP server stops
|
|
7
|
-
|
|
8
|
-
API Endpoints used:
|
|
9
|
-
- GET /api/daas/virtual-mobiles/{id} - Fetch device info by ID or reference name
|
|
10
|
-
- POST /api/daas/virtual-mobiles/{id}/keep-alive - Keep device alive (prevents billing timeout)
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import asyncio
|
|
14
|
-
import logging
|
|
15
|
-
from contextvars import ContextVar
|
|
16
|
-
from dataclasses import dataclass
|
|
17
|
-
from typing import Any
|
|
18
|
-
|
|
19
|
-
import aiohttp
|
|
20
|
-
|
|
21
|
-
from minitap.mcp.core.config import settings
|
|
22
|
-
from minitap.mcp.core.device import DeviceNotReadyError
|
|
23
|
-
|
|
24
|
-
logger = logging.getLogger(__name__)
|
|
25
|
-
|
|
26
|
-
# Context variable to store cloud mobile ID accessible from any MCP tool
|
|
27
|
-
# This is server-wide (not request-scoped) as it persists for the MCP lifecycle
|
|
28
|
-
_cloud_mobile_id: ContextVar[str | None] = ContextVar("cloud_mobile_id", default=None)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def get_cloud_mobile_id() -> str | None:
|
|
32
|
-
"""Get the current cloud mobile ID (UUID) from context.
|
|
33
|
-
|
|
34
|
-
Returns:
|
|
35
|
-
The cloud mobile UUID if running in cloud mode, None otherwise.
|
|
36
|
-
"""
|
|
37
|
-
return _cloud_mobile_id.get()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def set_cloud_mobile_id(mobile_id: str | None) -> None:
|
|
41
|
-
"""Set the cloud mobile ID in context.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
mobile_id: The cloud mobile UUID or None to clear.
|
|
45
|
-
"""
|
|
46
|
-
_cloud_mobile_id.set(mobile_id)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclass
|
|
50
|
-
class VirtualMobileInfo:
|
|
51
|
-
"""Information about a virtual mobile from the API."""
|
|
52
|
-
|
|
53
|
-
id: str # UUID
|
|
54
|
-
reference_name: str | None
|
|
55
|
-
state: str # Ready, Starting, Stopping, Stopped, Error
|
|
56
|
-
uptime_seconds: int
|
|
57
|
-
cost_micro_dollars: int
|
|
58
|
-
|
|
59
|
-
@classmethod
|
|
60
|
-
def from_api_response(cls, data: dict[str, Any]) -> "VirtualMobileInfo":
|
|
61
|
-
"""Create from API response."""
|
|
62
|
-
return cls(
|
|
63
|
-
id=data["id"],
|
|
64
|
-
reference_name=data.get("referenceName"),
|
|
65
|
-
state=data.get("state", {}).get("current", "Unknown"),
|
|
66
|
-
uptime_seconds=data.get("uptimeSeconds", 0),
|
|
67
|
-
cost_micro_dollars=data.get("costMicroDollars", 0),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
class CloudMobileService:
|
|
72
|
-
"""Service for managing cloud mobile connections via HTTP API.
|
|
73
|
-
|
|
74
|
-
This service handles:
|
|
75
|
-
1. Fetching cloud mobile info by name/UUID
|
|
76
|
-
2. Sending keep-alive pings to prevent idle shutdown
|
|
77
|
-
3. Proper cleanup on MCP server shutdown
|
|
78
|
-
|
|
79
|
-
The keep-alive is CRITICAL for billing - the platform will shut down
|
|
80
|
-
idle VMs after a timeout. The MCP server must send keep-alives while
|
|
81
|
-
waiting for user commands.
|
|
82
|
-
"""
|
|
83
|
-
|
|
84
|
-
KEEP_ALIVE_INTERVAL_SECONDS = 30
|
|
85
|
-
API_TIMEOUT_SECONDS = 30
|
|
86
|
-
|
|
87
|
-
def __init__(self, cloud_mobile_name: str, api_key: str):
|
|
88
|
-
"""Initialize the cloud mobile service.
|
|
89
|
-
|
|
90
|
-
Args:
|
|
91
|
-
cloud_mobile_name: The reference name or UUID of the cloud mobile.
|
|
92
|
-
api_key: The Minitap API key for authentication.
|
|
93
|
-
"""
|
|
94
|
-
self.cloud_mobile_name = cloud_mobile_name
|
|
95
|
-
self.api_key = api_key
|
|
96
|
-
self._base_url = settings.MINITAP_DAAS_API.rstrip("/")
|
|
97
|
-
self._mobile_id: str | None = None # UUID, resolved from name
|
|
98
|
-
self._mobile_info: VirtualMobileInfo | None = None
|
|
99
|
-
self._keep_alive_task: asyncio.Task | None = None
|
|
100
|
-
self._stop_event = asyncio.Event()
|
|
101
|
-
self._session: aiohttp.ClientSession | None = None
|
|
102
|
-
|
|
103
|
-
def _get_headers(self) -> dict[str, str]:
|
|
104
|
-
"""Get HTTP headers for API requests."""
|
|
105
|
-
return {
|
|
106
|
-
"Authorization": f"Bearer {self.api_key}",
|
|
107
|
-
"Content-Type": "application/json",
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
111
|
-
"""Get or create the aiohttp session."""
|
|
112
|
-
if self._session is None or self._session.closed:
|
|
113
|
-
timeout = aiohttp.ClientTimeout(total=self.API_TIMEOUT_SECONDS)
|
|
114
|
-
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
115
|
-
return self._session
|
|
116
|
-
|
|
117
|
-
async def _close_session(self) -> None:
|
|
118
|
-
"""Close the aiohttp session."""
|
|
119
|
-
if self._session and not self._session.closed:
|
|
120
|
-
await self._session.close()
|
|
121
|
-
self._session = None
|
|
122
|
-
|
|
123
|
-
async def _fetch_virtual_mobile(self) -> VirtualMobileInfo:
|
|
124
|
-
"""Fetch virtual mobile info from API.
|
|
125
|
-
|
|
126
|
-
GET /api/daas/virtual-mobiles/{id}
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
id can be UUID or reference name.
|
|
130
|
-
|
|
131
|
-
Returns:
|
|
132
|
-
VirtualMobileInfo with device details.
|
|
133
|
-
|
|
134
|
-
Raises:
|
|
135
|
-
RuntimeError: If device not found or API error.
|
|
136
|
-
"""
|
|
137
|
-
session = await self._ensure_session()
|
|
138
|
-
url = f"{self._base_url}/virtual-mobiles/{self.cloud_mobile_name}"
|
|
139
|
-
|
|
140
|
-
logger.debug(f"Fetching virtual mobile: {url}")
|
|
141
|
-
|
|
142
|
-
async with session.get(url, headers=self._get_headers()) as response:
|
|
143
|
-
if response.status == 404:
|
|
144
|
-
raise RuntimeError(
|
|
145
|
-
f"Cloud mobile '{self.cloud_mobile_name}' not found. "
|
|
146
|
-
"Please verify the name/UUID exists in your Minitap Platform account."
|
|
147
|
-
)
|
|
148
|
-
if response.status == 401:
|
|
149
|
-
raise RuntimeError(
|
|
150
|
-
"Authentication failed. Please verify your MINITAP_API_KEY is valid."
|
|
151
|
-
)
|
|
152
|
-
if response.status == 403:
|
|
153
|
-
raise RuntimeError(
|
|
154
|
-
f"Access denied to cloud mobile '{self.cloud_mobile_name}'. "
|
|
155
|
-
"Please verify your API key has access to this device."
|
|
156
|
-
)
|
|
157
|
-
if response.status != 200:
|
|
158
|
-
error_text = await response.text()
|
|
159
|
-
raise RuntimeError(
|
|
160
|
-
f"Failed to fetch cloud mobile: HTTP {response.status} - {error_text}"
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
data = await response.json()
|
|
164
|
-
return VirtualMobileInfo.from_api_response(data)
|
|
165
|
-
|
|
166
|
-
async def _send_keep_alive(self) -> bool:
|
|
167
|
-
"""Send keep-alive ping to prevent idle shutdown.
|
|
168
|
-
|
|
169
|
-
POST /api/daas/virtual-mobiles/{id}/keep-alive
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
True if successful, False otherwise.
|
|
173
|
-
"""
|
|
174
|
-
if not self._mobile_id:
|
|
175
|
-
logger.warning("Cannot send keep-alive: no mobile ID")
|
|
176
|
-
return False
|
|
177
|
-
|
|
178
|
-
session = await self._ensure_session()
|
|
179
|
-
url = f"{self._base_url}/virtual-mobiles/{self._mobile_id}/keep-alive"
|
|
180
|
-
|
|
181
|
-
try:
|
|
182
|
-
async with session.post(url, headers=self._get_headers()) as response:
|
|
183
|
-
if response.status == 204:
|
|
184
|
-
logger.debug(f"Keep-alive sent successfully for {self._mobile_id}")
|
|
185
|
-
return True
|
|
186
|
-
else:
|
|
187
|
-
error_text = await response.text()
|
|
188
|
-
logger.warning(f"Keep-alive failed: HTTP {response.status} - {error_text}")
|
|
189
|
-
return False
|
|
190
|
-
except Exception as e:
|
|
191
|
-
logger.error(f"Keep-alive request failed: {e}")
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
|
-
async def connect(self) -> None:
|
|
195
|
-
"""Connect to the cloud mobile and start keep-alive polling.
|
|
196
|
-
|
|
197
|
-
1. Fetches device info to verify it exists
|
|
198
|
-
2. Stores the UUID for keep-alive calls
|
|
199
|
-
3. Starts background keep-alive polling
|
|
200
|
-
|
|
201
|
-
Raises:
|
|
202
|
-
RuntimeError: If no cloud mobile is found with the given name.
|
|
203
|
-
"""
|
|
204
|
-
logger.info(f"Connecting to cloud mobile: {self.cloud_mobile_name}")
|
|
205
|
-
|
|
206
|
-
try:
|
|
207
|
-
# Fetch device info to verify it exists and get UUID
|
|
208
|
-
self._mobile_info = await self._fetch_virtual_mobile()
|
|
209
|
-
self._mobile_id = self._mobile_info.id
|
|
210
|
-
|
|
211
|
-
logger.info(
|
|
212
|
-
f"Connected to cloud mobile: {self.cloud_mobile_name} "
|
|
213
|
-
f"(id={self._mobile_id}, state={self._mobile_info.state})"
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
# Check if device is in a usable state
|
|
217
|
-
if self._mobile_info.state not in ("Ready", "Starting"):
|
|
218
|
-
logger.warning(
|
|
219
|
-
f"Cloud mobile state is '{self._mobile_info.state}'. "
|
|
220
|
-
"Device may not be ready for use."
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
# Store the mobile ID in context for access from MCP tools
|
|
224
|
-
set_cloud_mobile_id(self._mobile_id)
|
|
225
|
-
|
|
226
|
-
# Send initial keep-alive
|
|
227
|
-
await self._send_keep_alive()
|
|
228
|
-
|
|
229
|
-
# Start keep-alive polling
|
|
230
|
-
self._stop_event.clear()
|
|
231
|
-
self._keep_alive_task = asyncio.create_task(
|
|
232
|
-
self._keep_alive_loop(),
|
|
233
|
-
name=f"cloud_mobile_keep_alive_{self._mobile_id}",
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
except Exception as e:
|
|
237
|
-
logger.error(f"Failed to connect to cloud mobile '{self.cloud_mobile_name}': {e}")
|
|
238
|
-
await self._close_session()
|
|
239
|
-
raise RuntimeError(
|
|
240
|
-
f"Failed to connect to cloud mobile '{self.cloud_mobile_name}'. "
|
|
241
|
-
"Please verify:\n"
|
|
242
|
-
" 1. The cloud mobile exists in your Minitap Platform account\n"
|
|
243
|
-
" 2. The CLOUD_MOBILE_NAME matches exactly (case-sensitive)\n"
|
|
244
|
-
" 3. Your MINITAP_API_KEY has access to this cloud mobile\n"
|
|
245
|
-
f"Original error: {e}"
|
|
246
|
-
) from e
|
|
247
|
-
|
|
248
|
-
async def _keep_alive_loop(self) -> None:
|
|
249
|
-
"""Background task that sends periodic keep-alive pings.
|
|
250
|
-
|
|
251
|
-
This maintains the connection to the cloud mobile and prevents
|
|
252
|
-
idle shutdown (which would stop billing but also lose the session).
|
|
253
|
-
"""
|
|
254
|
-
logger.info(
|
|
255
|
-
f"Starting cloud mobile keep-alive polling "
|
|
256
|
-
f"(interval={self.KEEP_ALIVE_INTERVAL_SECONDS}s)"
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
consecutive_failures = 0
|
|
260
|
-
max_failures = 3
|
|
261
|
-
|
|
262
|
-
while not self._stop_event.is_set():
|
|
263
|
-
try:
|
|
264
|
-
# Wait for the interval, but check stop_event frequently
|
|
265
|
-
for _ in range(self.KEEP_ALIVE_INTERVAL_SECONDS * 10):
|
|
266
|
-
if self._stop_event.is_set():
|
|
267
|
-
break
|
|
268
|
-
await asyncio.sleep(0.1)
|
|
269
|
-
|
|
270
|
-
if self._stop_event.is_set():
|
|
271
|
-
break
|
|
272
|
-
|
|
273
|
-
# Send keep-alive ping
|
|
274
|
-
success = await self._send_keep_alive()
|
|
275
|
-
|
|
276
|
-
if success:
|
|
277
|
-
consecutive_failures = 0
|
|
278
|
-
else:
|
|
279
|
-
consecutive_failures += 1
|
|
280
|
-
if consecutive_failures >= max_failures:
|
|
281
|
-
logger.error(
|
|
282
|
-
f"Keep-alive failed {max_failures} times consecutively. "
|
|
283
|
-
"Cloud mobile may have been shut down."
|
|
284
|
-
)
|
|
285
|
-
# Don't stop the loop - keep trying in case it recovers
|
|
286
|
-
|
|
287
|
-
except asyncio.CancelledError:
|
|
288
|
-
logger.info("Cloud mobile keep-alive task cancelled")
|
|
289
|
-
break
|
|
290
|
-
except Exception as e:
|
|
291
|
-
logger.error(f"Error in cloud mobile keep-alive: {e}")
|
|
292
|
-
consecutive_failures += 1
|
|
293
|
-
# Don't break on errors, try to continue
|
|
294
|
-
await asyncio.sleep(5)
|
|
295
|
-
|
|
296
|
-
logger.info("Cloud mobile keep-alive polling stopped")
|
|
297
|
-
|
|
298
|
-
async def disconnect(self) -> None:
|
|
299
|
-
"""Disconnect from the cloud mobile and stop keep-alive polling.
|
|
300
|
-
|
|
301
|
-
This MUST be called when the MCP server shuts down to:
|
|
302
|
-
1. Stop keep-alive polling (allows VM to idle-shutdown if not used)
|
|
303
|
-
2. Clean up HTTP session
|
|
304
|
-
|
|
305
|
-
Note: We intentionally do NOT call a "stop" endpoint here.
|
|
306
|
-
Stopping keep-alive will let the VM idle-shutdown naturally
|
|
307
|
-
after its configured timeout, which is the expected behavior.
|
|
308
|
-
"""
|
|
309
|
-
logger.info(f"Disconnecting from cloud mobile: {self.cloud_mobile_name}")
|
|
310
|
-
|
|
311
|
-
# Signal the keep-alive loop to stop
|
|
312
|
-
self._stop_event.set()
|
|
313
|
-
|
|
314
|
-
# Cancel and wait for keep-alive task
|
|
315
|
-
if self._keep_alive_task and not self._keep_alive_task.done():
|
|
316
|
-
self._keep_alive_task.cancel()
|
|
317
|
-
try:
|
|
318
|
-
await asyncio.wait_for(self._keep_alive_task, timeout=5.0)
|
|
319
|
-
except TimeoutError:
|
|
320
|
-
logger.warning("Keep-alive task did not stop in time")
|
|
321
|
-
except asyncio.CancelledError:
|
|
322
|
-
pass
|
|
323
|
-
|
|
324
|
-
# Close HTTP session
|
|
325
|
-
await self._close_session()
|
|
326
|
-
|
|
327
|
-
# Clear the context
|
|
328
|
-
set_cloud_mobile_id(None)
|
|
329
|
-
self._mobile_id = None
|
|
330
|
-
self._mobile_info = None
|
|
331
|
-
|
|
332
|
-
logger.info("Cloud mobile disconnected (keep-alive stopped)")
|
|
333
|
-
|
|
334
|
-
@property
|
|
335
|
-
def mobile_id(self) -> str | None:
|
|
336
|
-
"""Get the cloud mobile UUID."""
|
|
337
|
-
return self._mobile_id
|
|
338
|
-
|
|
339
|
-
@property
|
|
340
|
-
def mobile_info(self) -> VirtualMobileInfo | None:
|
|
341
|
-
"""Get the cloud mobile info."""
|
|
342
|
-
return self._mobile_info
|
|
343
|
-
|
|
344
|
-
@property
|
|
345
|
-
def is_connected(self) -> bool:
|
|
346
|
-
"""Check if connected to a cloud mobile."""
|
|
347
|
-
return self._mobile_id is not None
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
async def get_cloud_screenshot(mobile_id: str | None = None) -> bytes:
|
|
351
|
-
"""Get a screenshot from a cloud mobile device.
|
|
352
|
-
|
|
353
|
-
GET /api/daas/virtual-mobiles/{id}/screenshot
|
|
354
|
-
|
|
355
|
-
Args:
|
|
356
|
-
mobile_id: The cloud mobile UUID. If None, uses the current context.
|
|
357
|
-
|
|
358
|
-
Returns:
|
|
359
|
-
Screenshot image bytes (PNG format).
|
|
360
|
-
|
|
361
|
-
Raises:
|
|
362
|
-
RuntimeError: If no cloud mobile is connected or screenshot fails.
|
|
363
|
-
"""
|
|
364
|
-
target_id = mobile_id or get_cloud_mobile_id()
|
|
365
|
-
|
|
366
|
-
if not target_id:
|
|
367
|
-
raise RuntimeError(
|
|
368
|
-
"No cloud mobile connected. "
|
|
369
|
-
"Either provide a mobile_id or ensure CLOUD_MOBILE_NAME is set."
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
|
|
373
|
-
if not api_key:
|
|
374
|
-
raise RuntimeError("MINITAP_API_KEY is required for cloud screenshot.")
|
|
375
|
-
|
|
376
|
-
base_url = settings.MINITAP_DAAS_API.rstrip("/")
|
|
377
|
-
url = f"{base_url}/virtual-mobiles/{target_id}/screenshot"
|
|
378
|
-
|
|
379
|
-
headers = {
|
|
380
|
-
"Authorization": f"Bearer {api_key}",
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
timeout = aiohttp.ClientTimeout(total=30)
|
|
384
|
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
385
|
-
async with session.get(url, headers=headers) as response:
|
|
386
|
-
if response.status == 404:
|
|
387
|
-
raise RuntimeError(f"Cloud mobile '{target_id}' not found.")
|
|
388
|
-
if response.status == 401:
|
|
389
|
-
raise RuntimeError("Authentication failed for cloud screenshot.")
|
|
390
|
-
if response.status == 403:
|
|
391
|
-
raise RuntimeError(f"Access denied to cloud mobile '{target_id}'.")
|
|
392
|
-
if response.status != 200:
|
|
393
|
-
error_text = await response.text()
|
|
394
|
-
raise RuntimeError(
|
|
395
|
-
f"Failed to get cloud screenshot: HTTP {response.status} - {error_text}"
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
return await response.read()
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
async def check_cloud_mobile_status(
|
|
402
|
-
cloud_mobile_name: str | None = None,
|
|
403
|
-
) -> VirtualMobileInfo:
|
|
404
|
-
"""Check the current status of a cloud mobile device.
|
|
405
|
-
|
|
406
|
-
This function checks the device state once and raises an appropriate error
|
|
407
|
-
if the device is not ready. The MCP client can then decide to retry.
|
|
408
|
-
|
|
409
|
-
Args:
|
|
410
|
-
cloud_mobile_name: The reference name or UUID of the cloud mobile.
|
|
411
|
-
If None, uses CLOUD_MOBILE_NAME from settings.
|
|
412
|
-
|
|
413
|
-
Returns:
|
|
414
|
-
VirtualMobileInfo: Device info if ready.
|
|
415
|
-
|
|
416
|
-
Raises:
|
|
417
|
-
DeviceNotReadyError: If device is not ready (starting, stopping, etc.).
|
|
418
|
-
RuntimeError: If device is in an error state, stopped, or not found.
|
|
419
|
-
"""
|
|
420
|
-
target_name = cloud_mobile_name or settings.CLOUD_MOBILE_NAME
|
|
421
|
-
if not target_name:
|
|
422
|
-
raise RuntimeError(
|
|
423
|
-
"No cloud mobile specified. Either provide cloud_mobile_name or set CLOUD_MOBILE_NAME."
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
|
|
427
|
-
if not api_key:
|
|
428
|
-
raise RuntimeError("MINITAP_API_KEY is required for cloud mobile operations.")
|
|
429
|
-
|
|
430
|
-
base_url = settings.MINITAP_DAAS_API.rstrip("/")
|
|
431
|
-
url = f"{base_url}/virtual-mobiles/{target_name}"
|
|
432
|
-
headers = {
|
|
433
|
-
"Authorization": f"Bearer {api_key}",
|
|
434
|
-
"Content-Type": "application/json",
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
timeout = aiohttp.ClientTimeout(total=30)
|
|
438
|
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
439
|
-
async with session.get(url, headers=headers) as response:
|
|
440
|
-
if response.status == 404:
|
|
441
|
-
raise RuntimeError(
|
|
442
|
-
f"Cloud mobile '{target_name}' not found. "
|
|
443
|
-
"Please verify the name/UUID exists in your Minitap Platform account."
|
|
444
|
-
)
|
|
445
|
-
if response.status == 401:
|
|
446
|
-
raise RuntimeError(
|
|
447
|
-
"Authentication failed. Please verify your MINITAP_API_KEY is valid."
|
|
448
|
-
)
|
|
449
|
-
if response.status != 200:
|
|
450
|
-
error_text = await response.text()
|
|
451
|
-
raise RuntimeError(
|
|
452
|
-
f"Failed to fetch cloud mobile: HTTP {response.status} - {error_text}"
|
|
453
|
-
)
|
|
454
|
-
|
|
455
|
-
data = await response.json()
|
|
456
|
-
info = VirtualMobileInfo.from_api_response(data)
|
|
457
|
-
|
|
458
|
-
if info.state == "Ready":
|
|
459
|
-
logger.info(f"Cloud mobile '{target_name}' is ready")
|
|
460
|
-
return info
|
|
461
|
-
|
|
462
|
-
if info.state == "Error":
|
|
463
|
-
raise RuntimeError(
|
|
464
|
-
f"Cloud mobile '{target_name}' is in error state. "
|
|
465
|
-
"Please check the Minitap Platform for details."
|
|
466
|
-
)
|
|
467
|
-
|
|
468
|
-
if info.state == "Stopped":
|
|
469
|
-
raise DeviceNotReadyError(
|
|
470
|
-
f"Cloud mobile '{target_name}' is stopped. ",
|
|
471
|
-
state=info.state,
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
if info.state == "Starting":
|
|
475
|
-
raise DeviceNotReadyError(
|
|
476
|
-
f"Cloud mobile '{target_name}' is still starting. "
|
|
477
|
-
"Please wait a minute and try again.",
|
|
478
|
-
state=info.state,
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
if info.state == "Stopping":
|
|
482
|
-
raise DeviceNotReadyError(
|
|
483
|
-
f"Cloud mobile '{target_name}' is stopping. ",
|
|
484
|
-
state=info.state,
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
# Unknown state
|
|
488
|
-
raise DeviceNotReadyError(
|
|
489
|
-
f"Cloud mobile '{target_name}' is in state '{info.state}'. "
|
|
490
|
-
"Please check the Minitap Platform for details.",
|
|
491
|
-
state=info.state,
|
|
492
|
-
)
|
|
File without changes
|