minitap-mcp 0.6.0__py3-none-any.whl → 0.8.0__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.
@@ -1,13 +1,35 @@
1
1
  """Configuration for the MCP server."""
2
2
 
3
+ from urllib.parse import urlparse
4
+
3
5
  from dotenv import load_dotenv
4
- from pydantic import Field, SecretStr
6
+ from pydantic import Field, SecretStr, model_validator
5
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
6
8
 
7
9
  # Load environment variables from .env file
8
10
  load_dotenv(verbose=True)
9
11
 
10
12
 
13
+ def _derive_mcp_url_from_base(base_url: str) -> str:
14
+ """Derive the MCP URL from the API base URL.
15
+
16
+ Extracts the scheme and host from the base URL and appends /api/mcp.
17
+ Example: https://dev.platform.minitap.ai/api/v1 -> https://dev.platform.minitap.ai/api/mcp
18
+ """
19
+ parsed = urlparse(base_url)
20
+ return f"{parsed.scheme}://{parsed.netloc}/api/mcp"
21
+
22
+
23
+ def _derive_daas_url_from_base(base_url: str) -> str:
24
+ """Derive the DaaS API URL from the API base URL.
25
+
26
+ Extracts the scheme and host from the base URL and appends /api/daas.
27
+ Example: https://dev.platform.minitap.ai/api/v1 -> https://dev.platform.minitap.ai/api/daas
28
+ """
29
+ parsed = urlparse(base_url)
30
+ return f"{parsed.scheme}://{parsed.netloc}/api/daas"
31
+
32
+
11
33
  class MCPSettings(BaseSettings):
12
34
  """Configuration class for MCP server."""
13
35
 
@@ -16,13 +38,14 @@ class MCPSettings(BaseSettings):
16
38
  # Minitap API configuration
17
39
  MINITAP_API_KEY: SecretStr | None = Field(default=None)
18
40
  MINITAP_API_BASE_URL: str = Field(default="https://platform.minitap.ai/api/v1")
19
- MINITAP_DAAS_API: str = Field(default="https://platform.minitap.ai/api/daas")
20
- OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
21
41
 
22
- VISION_MODEL: str = Field(default="qwen/qwen-2.5-vl-7b-instruct")
42
+ # These URLs can be set explicitly, or will be derived from MINITAP_API_BASE_URL
43
+ MINITAP_DAAS_API: str | None = Field(default=None)
44
+ MINITAP_API_MCP_BASE_URL: str | None = Field(default=None)
45
+
46
+ OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
23
47
 
24
- # Figma MCP server configuration
25
- FIGMA_MCP_SERVER_URL: str = Field(default="http://127.0.0.1:3845/mcp")
48
+ VISION_MODEL: str = Field(default="google/gemini-3-flash-preview")
26
49
 
27
50
  # MCP server configuration (optional, for remote access)
28
51
  MCP_SERVER_HOST: str = Field(default="0.0.0.0")
@@ -34,5 +57,29 @@ class MCPSettings(BaseSettings):
34
57
  # Create cloud mobiles at https://platform.minitap.ai/cloud-mobiles
35
58
  CLOUD_MOBILE_NAME: str | None = Field(default=None)
36
59
 
60
+ @model_validator(mode="after")
61
+ def derive_urls_from_base(self) -> "MCPSettings":
62
+ """Derive MCP and DaaS URLs from base URL if not explicitly set.
63
+
64
+ This ensures that setting MINITAP_API_BASE_URL to a different environment
65
+ (e.g., dev) automatically updates all related URLs.
66
+ """
67
+ if self.MINITAP_API_MCP_BASE_URL is None:
68
+ # Use object.__setattr__ to bypass Pydantic's frozen model protection
69
+ object.__setattr__(
70
+ self,
71
+ "MINITAP_API_MCP_BASE_URL",
72
+ _derive_mcp_url_from_base(self.MINITAP_API_BASE_URL),
73
+ )
74
+
75
+ if self.MINITAP_DAAS_API is None:
76
+ object.__setattr__(
77
+ self,
78
+ "MINITAP_DAAS_API",
79
+ _derive_daas_url_from_base(self.MINITAP_API_BASE_URL),
80
+ )
81
+
82
+ return self
83
+
37
84
 
38
85
  settings = MCPSettings() # type: ignore
@@ -6,7 +6,7 @@ from collections.abc import Callable
6
6
  from functools import wraps
7
7
  from typing import Any, TypeVar
8
8
 
9
- from minitap.mcp.core.device import DeviceNotFoundError
9
+ from minitap.mcp.core.device import DeviceNotFoundError, DeviceNotReadyError
10
10
  from minitap.mcp.core.logging_config import get_logger
11
11
 
12
12
  F = TypeVar("F", bound=Callable[..., Any])
@@ -42,6 +42,15 @@ def handle_tool_errors[T: Callable[..., Any]](func: T) -> T:
42
42
  error_type=type(e).__name__,
43
43
  )
44
44
  return f"Error: {str(e)}"
45
+ except DeviceNotReadyError as e:
46
+ logger.error(
47
+ "device_not_ready_error",
48
+ tool_name=func.__name__,
49
+ error=str(e),
50
+ error_type=type(e).__name__,
51
+ device_state=e.state,
52
+ )
53
+ return f"Error: {str(e)}"
45
54
  except Exception as e:
46
55
  logger.error(
47
56
  "tool_error",
@@ -72,6 +81,15 @@ def handle_tool_errors[T: Callable[..., Any]](func: T) -> T:
72
81
  error_type=type(e).__name__,
73
82
  )
74
83
  return f"Error: {str(e)}"
84
+ except DeviceNotReadyError as e:
85
+ logger.error(
86
+ "device_not_ready_error",
87
+ tool_name=func.__name__,
88
+ error=str(e),
89
+ error_type=type(e).__name__,
90
+ device_state=e.state,
91
+ )
92
+ return f"Error: {str(e)}"
75
93
  except Exception as e:
76
94
  logger.error(
77
95
  "tool_error",
@@ -11,7 +11,6 @@ from typing import Literal
11
11
  from adbutils import AdbClient, AdbDevice
12
12
  from pydantic import BaseModel, ConfigDict
13
13
 
14
-
15
14
  DevicePlatform = Literal["android", "ios"]
16
15
 
17
16
 
@@ -40,6 +39,14 @@ class DeviceNotFoundError(Exception):
40
39
  pass
41
40
 
42
41
 
42
+ class DeviceNotReadyError(Exception):
43
+ """Raised when a device exists but is not ready (e.g., still starting)."""
44
+
45
+ def __init__(self, message: str, state: str | None = None):
46
+ super().__init__(message)
47
+ self.state = state
48
+
49
+
43
50
  def get_adb_client() -> AdbClient:
44
51
  """Get an ADB client instance."""
45
52
  custom_adb_socket = os.getenv("ADB_SERVER_SOCKET")
@@ -80,9 +87,9 @@ def list_available_devices() -> list[DeviceInfo]:
80
87
  # ADB not available or error listing devices
81
88
  pass
82
89
 
83
- # List iOS devices
90
+ # List iOS devices (only booted simulators to match SDK behavior)
84
91
  try:
85
- cmd = ["xcrun", "simctl", "list", "devices", "-j"]
92
+ cmd = ["xcrun", "simctl", "list", "devices", "booted", "-j"]
86
93
  result = subprocess.run(cmd, capture_output=True, text=True, check=True)
87
94
  data = json.loads(result.stdout)
88
95
 
@@ -95,7 +102,7 @@ def list_available_devices() -> list[DeviceInfo]:
95
102
  name = device.get("name")
96
103
  state = device.get("state")
97
104
 
98
- if udid:
105
+ if udid and state == "Booted":
99
106
  devices.append(
100
107
  DeviceInfo(
101
108
  device_id=udid,
@@ -0,0 +1,274 @@
1
+ """Storage utilities for uploading local files to remote storage.
2
+
3
+ This module provides functionality to upload local files (like screenshots)
4
+ to the MaaS API storage backend and get presigned URLs that can be passed
5
+ to remote MCP tools.
6
+ """
7
+
8
+ import base64
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+
14
+ from minitap.mcp.core.config import settings
15
+ from minitap.mcp.core.logging_config import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class StorageUploadError(Exception):
21
+ """Error raised when file upload fails."""
22
+
23
+ pass
24
+
25
+
26
+ def _get_api_key() -> str:
27
+ """Get the API key from settings.
28
+
29
+ Returns:
30
+ The API key string
31
+
32
+ Raises:
33
+ StorageUploadError: If API key is not configured
34
+ """
35
+ api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
36
+ if not api_key:
37
+ raise StorageUploadError("MINITAP_API_KEY is required for file uploads")
38
+ return api_key
39
+
40
+
41
+ def _generate_filename(content_type: str) -> str:
42
+ """Generate a unique filename based on content type.
43
+
44
+ Args:
45
+ content_type: MIME type of the file
46
+
47
+ Returns:
48
+ UUID-based filename with appropriate extension
49
+ """
50
+ ext = _get_extension_from_mime_type(content_type)
51
+ return f"{uuid.uuid4()}.{ext}"
52
+
53
+
54
+ async def _get_signed_upload_url(
55
+ client: httpx.AsyncClient,
56
+ filename: str,
57
+ api_key: str,
58
+ ) -> str:
59
+ """Get a signed upload URL from the MaaS API.
60
+
61
+ Args:
62
+ client: HTTP client to use for the request
63
+ filename: Name of the file to upload
64
+ api_key: API key for authentication
65
+
66
+ Returns:
67
+ Signed upload URL
68
+
69
+ Raises:
70
+ StorageUploadError: If request fails or no URL is returned
71
+ """
72
+ base_url = settings.MINITAP_API_BASE_URL
73
+ endpoint = f"{base_url}/storage/signed-upload"
74
+
75
+ try:
76
+ logger.debug("Requesting signed upload URL", filename=filename)
77
+ response = await client.get(
78
+ endpoint,
79
+ params={"filenames": filename},
80
+ headers={"Authorization": f"Bearer {api_key}"},
81
+ )
82
+
83
+ if response.status_code != 200:
84
+ logger.error(
85
+ "Failed to get signed upload URL",
86
+ status_code=response.status_code,
87
+ response=response.text,
88
+ )
89
+ raise StorageUploadError(
90
+ f"Failed to get signed upload URL: HTTP {response.status_code}"
91
+ )
92
+
93
+ signed_urls = response.json().get("signed_urls", {})
94
+ if filename not in signed_urls:
95
+ raise StorageUploadError(f"No signed URL returned for {filename}")
96
+
97
+ logger.debug("Got signed upload URL", filename=filename)
98
+ return signed_urls[filename]
99
+
100
+ except httpx.TimeoutException as e:
101
+ logger.error("Signed URL request timed out", error=str(e))
102
+ raise StorageUploadError("Signed URL request timed out") from e
103
+ except httpx.RequestError as e:
104
+ logger.error("Signed URL request failed", error=str(e))
105
+ raise StorageUploadError(f"Signed URL request failed: {str(e)}") from e
106
+ except StorageUploadError:
107
+ raise
108
+ except Exception as e:
109
+ logger.error("Unexpected error getting signed URL", error=str(e))
110
+ raise StorageUploadError(f"Unexpected error: {str(e)}") from e
111
+
112
+
113
+ async def _upload_to_signed_url(
114
+ client: httpx.AsyncClient,
115
+ url: str,
116
+ content: bytes,
117
+ content_type: str,
118
+ filename: str,
119
+ ) -> None:
120
+ """Upload content to a signed URL.
121
+
122
+ Args:
123
+ client: HTTP client to use for the request
124
+ url: Signed upload URL
125
+ content: File content as bytes
126
+ content_type: MIME type of the content
127
+ filename: Filename (for logging)
128
+
129
+ Raises:
130
+ StorageUploadError: If upload fails
131
+ """
132
+ try:
133
+ logger.debug("Uploading file to storage", filename=filename, size=len(content))
134
+ response = await client.put(
135
+ url,
136
+ content=content,
137
+ headers={"Content-Type": content_type},
138
+ )
139
+
140
+ if response.status_code not in (200, 201):
141
+ logger.error(
142
+ "Failed to upload file",
143
+ status_code=response.status_code,
144
+ response=response.text,
145
+ )
146
+ raise StorageUploadError(f"Failed to upload file: HTTP {response.status_code}")
147
+
148
+ logger.info("File uploaded successfully", filename=filename)
149
+
150
+ except httpx.TimeoutException as e:
151
+ logger.error("Upload request timed out", error=str(e))
152
+ raise StorageUploadError("Upload request timed out") from e
153
+ except httpx.RequestError as e:
154
+ logger.error("Upload request failed", error=str(e))
155
+ raise StorageUploadError(f"Upload request failed: {str(e)}") from e
156
+ except StorageUploadError:
157
+ raise
158
+ except Exception as e:
159
+ logger.error("Unexpected error during upload", error=str(e))
160
+ raise StorageUploadError(f"Unexpected error: {str(e)}") from e
161
+
162
+
163
+ async def upload_file_to_storage(
164
+ file_content: bytes,
165
+ filename: str | None = None,
166
+ content_type: str = "image/png",
167
+ ) -> str:
168
+ """Upload file content to remote storage and return the filename.
169
+
170
+ This function:
171
+ 1. Gets a signed upload URL from the MaaS API
172
+ 2. Uploads the file content to that URL
173
+ 3. Returns the filename for use with remote MCP tools
174
+
175
+ Args:
176
+ file_content: The file content as bytes
177
+ filename: Optional filename (will generate UUID-based name if not provided)
178
+ content_type: MIME type of the file (default: image/png)
179
+
180
+ Returns:
181
+ Filename of the uploaded file (to be used with remote MCP tools)
182
+
183
+ Raises:
184
+ StorageUploadError: If upload fails at any step
185
+ """
186
+ api_key = _get_api_key()
187
+ filename = filename or _generate_filename(content_type)
188
+
189
+ async with httpx.AsyncClient(timeout=30.0) as client:
190
+ signed_url = await _get_signed_upload_url(client, filename, api_key)
191
+ await _upload_to_signed_url(client, signed_url, file_content, content_type, filename)
192
+
193
+ return filename
194
+
195
+
196
+ async def upload_screenshot_to_storage(screenshot_base64: str) -> str:
197
+ """Upload a base64-encoded screenshot to storage.
198
+
199
+ Convenience function for uploading screenshots captured from devices.
200
+
201
+ Args:
202
+ screenshot_base64: Base64-encoded screenshot data
203
+
204
+ Returns:
205
+ Filename of the uploaded screenshot
206
+
207
+ Raises:
208
+ StorageUploadError: If upload fails
209
+ """
210
+
211
+ try:
212
+ screenshot_bytes = base64.b64decode(screenshot_base64)
213
+ except Exception as e:
214
+ raise StorageUploadError(f"Invalid base64 data: {str(e)}") from e
215
+
216
+ return await upload_file_to_storage(
217
+ file_content=screenshot_bytes,
218
+ content_type="image/png",
219
+ )
220
+
221
+
222
+ async def upload_local_file_to_storage(file_path: str | Path) -> str:
223
+ """Upload a local file to storage.
224
+
225
+ Args:
226
+ file_path: Path to the local file
227
+
228
+ Returns:
229
+ Public download URL for the uploaded file
230
+
231
+ Raises:
232
+ StorageUploadError: If file doesn't exist or upload fails
233
+ """
234
+ path = Path(file_path)
235
+
236
+ if not path.exists():
237
+ raise StorageUploadError(f"File not found: {file_path}")
238
+
239
+ mime_type = _guess_mime_type(path.suffix)
240
+ file_content = path.read_bytes()
241
+
242
+ return await upload_file_to_storage(
243
+ file_content=file_content,
244
+ filename=f"{uuid.uuid4()}{path.suffix}",
245
+ content_type=mime_type,
246
+ )
247
+
248
+
249
+ def _get_extension_from_mime_type(mime_type: str) -> str:
250
+ """Get file extension from MIME type."""
251
+ mime_to_ext = {
252
+ "image/png": "png",
253
+ "image/jpeg": "jpg",
254
+ "image/gif": "gif",
255
+ "image/webp": "webp",
256
+ "application/json": "json",
257
+ "text/plain": "txt",
258
+ }
259
+ return mime_to_ext.get(mime_type, "bin")
260
+
261
+
262
+ def _guess_mime_type(extension: str) -> str:
263
+ """Guess MIME type from file extension."""
264
+ ext = extension.lower().lstrip(".")
265
+ ext_to_mime = {
266
+ "png": "image/png",
267
+ "jpg": "image/jpeg",
268
+ "jpeg": "image/jpeg",
269
+ "gif": "image/gif",
270
+ "webp": "image/webp",
271
+ "json": "application/json",
272
+ "txt": "text/plain",
273
+ }
274
+ return ext_to_mime.get(ext, "application/octet-stream")
minitap/mcp/main.py CHANGED
@@ -25,7 +25,6 @@ if sys.platform == "win32":
25
25
 
26
26
 
27
27
  from fastmcp import FastMCP # noqa: E402
28
- from minitap.mobile_use.config import settings as sdk_settings
29
28
 
30
29
  from minitap.mcp.core.config import settings # noqa: E402
31
30
  from minitap.mcp.core.device import (
@@ -39,6 +38,7 @@ from minitap.mcp.core.logging_config import (
39
38
  from minitap.mcp.server.cloud_mobile import CloudMobileService
40
39
  from minitap.mcp.server.middleware import LocalDeviceHealthMiddleware
41
40
  from minitap.mcp.server.poller import device_health_poller
41
+ from minitap.mobile_use.config import settings as sdk_settings
42
42
 
43
43
  configure_logging(log_level=os.getenv("LOG_LEVEL", "INFO"))
44
44
 
@@ -126,6 +126,7 @@ class MCPLifespanContext:
126
126
  cloud_mobile_service: CloudMobileService | None = None
127
127
  local_poller_stop_event: threading.Event | None = None
128
128
  local_poller_thread: threading.Thread | None = None
129
+ remote_mcp_proxy: FastMCP | None = None
129
130
 
130
131
 
131
132
  @asynccontextmanager
@@ -210,6 +211,46 @@ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
210
211
  context.local_poller_thread = poller_thread
211
212
  logger.info("Device health poller started")
212
213
 
214
+ # ==================== REMOTE MCP PROXY ====================
215
+ # Mount remote MCP proxy if configured (works in both cloud and local modes)
216
+ if settings.MINITAP_API_MCP_BASE_URL and api_key:
217
+ from minitap.mcp.server.remote_proxy import (
218
+ check_remote_mcp_availability,
219
+ create_remote_mcp_proxy,
220
+ )
221
+
222
+ logger.info(f"Attempting to connect to remote MCP at: {settings.MINITAP_API_MCP_BASE_URL}")
223
+
224
+ try:
225
+ is_available = await check_remote_mcp_availability(
226
+ mcp_url=settings.MINITAP_API_MCP_BASE_URL,
227
+ api_key=api_key,
228
+ )
229
+
230
+ if is_available:
231
+ remote_proxy = create_remote_mcp_proxy(
232
+ mcp_url=settings.MINITAP_API_MCP_BASE_URL,
233
+ api_key=api_key,
234
+ prefix="",
235
+ )
236
+
237
+ server.mount(remote_proxy)
238
+ logger.info("Remote MCP proxy mounted successfully without prefix")
239
+
240
+ context.remote_mcp_proxy = remote_proxy
241
+ else:
242
+ logger.warning(
243
+ "Remote MCP server is not available. "
244
+ "Local tools will remain functional, but remote tools (Figma, Jira) "
245
+ "will not be accessible."
246
+ )
247
+ except Exception as e:
248
+ # Log warning but don't crash - local tools should remain functional
249
+ logger.warning(
250
+ f"Failed to create remote MCP proxy: {e}. "
251
+ "Local tools will remain functional, but remote tools will not be accessible."
252
+ )
253
+
213
254
  try:
214
255
  yield context
215
256
  finally:
@@ -236,6 +277,16 @@ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
236
277
  else:
237
278
  logger.info("Device health poller stopped successfully")
238
279
 
280
+ # Clean up remote MCP proxy connection
281
+ # basically not super important for now, but will be in the
282
+ # future when cloud dependencies must be cleaned up
283
+ if context.remote_mcp_proxy:
284
+ try:
285
+ logger.info("Cleaning up remote MCP proxy connection")
286
+ context.remote_mcp_proxy = None
287
+ except Exception as e:
288
+ logger.warning(f"Error cleaning up remote MCP proxy: {e}")
289
+
239
290
  logger.info("MCP server shutdown complete")
240
291
 
241
292
 
@@ -250,10 +301,11 @@ mcp = FastMCP(
250
301
  lifespan=mcp_lifespan,
251
302
  )
252
303
 
253
- from minitap.mcp.tools import analyze_screen # noqa: E402, F401
254
- from minitap.mcp.tools import compare_screenshot_with_figma # noqa: E402, F401
255
- from minitap.mcp.tools import execute_mobile_command # noqa: E402, F401
256
- from minitap.mcp.tools import save_figma_assets # noqa: E402, F401
304
+ from minitap.mcp.tools import ( # noqa: E402
305
+ execute_mobile_command, # noqa: E402, F401
306
+ read_swift_logs, # noqa: E402, F401
307
+ upload_screenshot, # noqa: E402, F401
308
+ )
257
309
 
258
310
 
259
311
  @mcp.resource("data://devices")
@@ -19,6 +19,7 @@ from typing import Any
19
19
  import aiohttp
20
20
 
21
21
  from minitap.mcp.core.config import settings
22
+ from minitap.mcp.core.device import DeviceNotReadyError
22
23
 
23
24
  logger = logging.getLogger(__name__)
24
25
 
@@ -395,3 +396,97 @@ async def get_cloud_screenshot(mobile_id: str | None = None) -> bytes:
395
396
  )
396
397
 
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
+ )