minitap-mcp 0.6.0__py3-none-any.whl → 0.7.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.
@@ -17,12 +17,10 @@ class MCPSettings(BaseSettings):
17
17
  MINITAP_API_KEY: SecretStr | None = Field(default=None)
18
18
  MINITAP_API_BASE_URL: str = Field(default="https://platform.minitap.ai/api/v1")
19
19
  MINITAP_DAAS_API: str = Field(default="https://platform.minitap.ai/api/daas")
20
+ MINITAP_API_MCP_BASE_URL: str | None = Field(default="https://platform.minitap.ai/mcp")
20
21
  OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
21
22
 
22
- VISION_MODEL: str = Field(default="qwen/qwen-2.5-vl-7b-instruct")
23
-
24
- # Figma MCP server configuration
25
- FIGMA_MCP_SERVER_URL: str = Field(default="http://127.0.0.1:3845/mcp")
23
+ VISION_MODEL: str = Field(default="google/gemini-3-flash-preview")
26
24
 
27
25
  # MCP server configuration (optional, for remote access)
28
26
  MCP_SERVER_HOST: str = Field(default="0.0.0.0")
@@ -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
+ )
@@ -0,0 +1,96 @@
1
+ """Remote MCP proxy for bridging with the MaaS API MCP server.
2
+
3
+ This module provides functionality to create a proxy to the remote MaaS API
4
+ MCP server, enabling the local MCP to expose remote tools (Figma, Jira, etc.)
5
+ through a unified interface.
6
+ """
7
+
8
+ import asyncio
9
+ from urllib.parse import urlparse
10
+
11
+ from fastmcp import FastMCP
12
+ from fastmcp.client.transports import StreamableHttpTransport
13
+ from fastmcp.server.proxy import ProxyClient
14
+
15
+ from minitap.mcp.core.logging_config import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ def create_remote_mcp_proxy(
21
+ mcp_url: str,
22
+ api_key: str,
23
+ prefix: str = "",
24
+ ) -> FastMCP:
25
+ """Create a proxy to the remote MaaS API MCP server.
26
+
27
+ This function creates a FastMCP proxy that connects to the remote MaaS API
28
+ MCP server using StreamableHTTP transport with Bearer authentication.
29
+
30
+ Args:
31
+ mcp_url: The URL of the remote MCP server (e.g., "http://127.0.0.1:8000/mcp").
32
+ api_key: The Minitap API key for authentication.
33
+ prefix: The prefix for remote tools (default: empty string, no prefix).
34
+
35
+ Returns:
36
+ A FastMCP proxy server configured to forward requests to the remote MCP.
37
+
38
+ Example:
39
+ >>> proxy = create_remote_mcp_proxy(
40
+ ... mcp_url="http://127.0.0.1:8000/mcp",
41
+ ... api_key="your-api-key"
42
+ ... )
43
+ >>> main_mcp.mount(proxy, prefix="remote")
44
+ """
45
+ logger.info(f"Creating remote MCP proxy for: {mcp_url}")
46
+
47
+ transport = StreamableHttpTransport(
48
+ url=mcp_url,
49
+ headers={
50
+ "Authorization": f"Bearer {api_key}",
51
+ },
52
+ )
53
+
54
+ proxy = FastMCP.as_proxy(
55
+ ProxyClient(transport),
56
+ name=f"remote-mcp-proxy-{prefix}",
57
+ )
58
+
59
+ logger.info(f"Remote MCP proxy created successfully with prefix: {prefix}")
60
+ return proxy
61
+
62
+
63
+ async def check_remote_mcp_availability(mcp_url: str, api_key: str) -> bool:
64
+ """Check if the remote MCP server is available by testing TCP connection.
65
+
66
+ Args:
67
+ mcp_url: The URL of the remote MCP server (e.g., http://localhost:8000/mcp).
68
+ api_key: The Minitap API key for authentication (unused, kept for API compat).
69
+
70
+ Returns:
71
+ True if the remote MCP is available, False otherwise.
72
+ """
73
+
74
+ parsed = urlparse(mcp_url)
75
+ host = parsed.hostname or "localhost"
76
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
77
+
78
+ try:
79
+ # Simple TCP connection check
80
+ _, writer = await asyncio.wait_for(
81
+ asyncio.open_connection(host, port),
82
+ timeout=5.0,
83
+ )
84
+ writer.close()
85
+ await writer.wait_closed()
86
+ logger.info(f"Remote MCP availability check: connected to {host}:{port}")
87
+ return True
88
+ except TimeoutError:
89
+ logger.warning(f"Remote MCP availability check timed out: {mcp_url}")
90
+ return False
91
+ except OSError as e:
92
+ logger.warning(f"Remote MCP connection failed: {mcp_url} - {e}")
93
+ return False
94
+ except Exception as e:
95
+ logger.warning(f"Remote MCP availability check failed: {e}")
96
+ return False
@@ -13,6 +13,7 @@ from minitap.mcp.core.config import settings
13
13
  from minitap.mcp.core.decorators import handle_tool_errors
14
14
  from minitap.mcp.core.sdk_agent import get_mobile_use_agent
15
15
  from minitap.mcp.main import mcp
16
+ from minitap.mcp.server.cloud_mobile import check_cloud_mobile_status
16
17
 
17
18
 
18
19
  def _serialize_result(result: Any) -> Any:
@@ -79,6 +80,9 @@ async def execute_mobile_command(
79
80
  ) -> str | dict[str, Any]:
80
81
  """Run a manual task on a mobile device via the Minitap platform."""
81
82
  try:
83
+ if settings.CLOUD_MOBILE_NAME:
84
+ await check_cloud_mobile_status(settings.CLOUD_MOBILE_NAME)
85
+
82
86
  if apk_path:
83
87
  if not settings.CLOUD_MOBILE_NAME:
84
88
  raise ToolError(