minitap-mcp 0.9.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.
- minitap/mcp/__init__.py +0 -0
- minitap/mcp/core/agents/compare_screenshots/agent.py +75 -0
- minitap/mcp/core/agents/compare_screenshots/eval/prompts/prompt_1.md +62 -0
- minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/actual.png +0 -0
- minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/figma.png +0 -0
- minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/human_feedback.txt +18 -0
- minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/model_params.json +3 -0
- minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/output.md +46 -0
- minitap/mcp/core/agents/compare_screenshots/prompt.md +62 -0
- minitap/mcp/core/cloud_apk.py +117 -0
- minitap/mcp/core/config.py +111 -0
- minitap/mcp/core/decorators.py +107 -0
- minitap/mcp/core/device.py +249 -0
- minitap/mcp/core/llm.py +39 -0
- minitap/mcp/core/logging_config.py +59 -0
- minitap/mcp/core/models.py +59 -0
- minitap/mcp/core/sdk_agent.py +35 -0
- minitap/mcp/core/storage.py +407 -0
- minitap/mcp/core/task_runs.py +100 -0
- minitap/mcp/core/utils/figma.py +69 -0
- minitap/mcp/core/utils/images.py +55 -0
- minitap/mcp/main.py +328 -0
- minitap/mcp/server/cloud_mobile.py +492 -0
- minitap/mcp/server/middleware.py +21 -0
- minitap/mcp/server/poller.py +78 -0
- minitap/mcp/server/remote_proxy.py +96 -0
- minitap/mcp/tools/execute_mobile_command.py +182 -0
- minitap/mcp/tools/read_swift_logs.py +297 -0
- minitap/mcp/tools/screen_analyzer.md +17 -0
- minitap/mcp/tools/take_screenshot.py +53 -0
- minitap/mcp/tools/upload_screenshot.py +80 -0
- minitap_mcp-0.9.0.dist-info/METADATA +352 -0
- minitap_mcp-0.9.0.dist-info/RECORD +35 -0
- minitap_mcp-0.9.0.dist-info/WHEEL +4 -0
- minitap_mcp-0.9.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,492 @@
|
|
|
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
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from fastmcp.exceptions import ToolError
|
|
2
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
3
|
+
from minitap.mobile_use.sdk import Agent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LocalDeviceHealthMiddleware(Middleware):
|
|
7
|
+
"""Middleware that checks local device health before tool calls.
|
|
8
|
+
|
|
9
|
+
Only used in local mode (when CLOUD_MOBILE_NAME is not set).
|
|
10
|
+
For cloud mode, device health is managed by the cloud service.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, agent: Agent):
|
|
14
|
+
self.agent = agent
|
|
15
|
+
|
|
16
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
17
|
+
if not self.agent._initialized:
|
|
18
|
+
raise ToolError(
|
|
19
|
+
"Agent not initialized.\nMake sure a mobile device is connected and try again."
|
|
20
|
+
)
|
|
21
|
+
return await call_next(context)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Device health monitoring poller for the MCP server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from minitap.mobile_use.sdk import Agent
|
|
8
|
+
|
|
9
|
+
from minitap.mcp.core.device import list_available_devices
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _async_device_health_poller(stop_event: threading.Event, agent: Agent) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Async implementation of device health poller.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
stop_event: Threading event to signal when to stop polling.
|
|
20
|
+
agent: The Agent instance to monitor and reinitialize if needed.
|
|
21
|
+
"""
|
|
22
|
+
while not stop_event.is_set():
|
|
23
|
+
try:
|
|
24
|
+
# Sleep in smaller chunks to be more responsive to stop signal
|
|
25
|
+
for _ in range(50): # 50 * 0.1 = 5 seconds total
|
|
26
|
+
if stop_event.is_set():
|
|
27
|
+
break
|
|
28
|
+
await asyncio.sleep(0.1)
|
|
29
|
+
|
|
30
|
+
if stop_event.is_set():
|
|
31
|
+
break
|
|
32
|
+
|
|
33
|
+
devices = list_available_devices()
|
|
34
|
+
|
|
35
|
+
if len(devices) > 0:
|
|
36
|
+
if not agent._initialized:
|
|
37
|
+
logger.warning("Agent is not initialized. Initializing...")
|
|
38
|
+
await agent.init()
|
|
39
|
+
logger.info("Agent initialized successfully")
|
|
40
|
+
else:
|
|
41
|
+
logger.info("No mobile device found, retrying in 5 seconds...")
|
|
42
|
+
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Error in device health poller: {e}")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if agent._initialized:
|
|
48
|
+
await agent.clean(force=True)
|
|
49
|
+
logger.info("Agent cleaned up successfully")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"Error cleaning up agent: {e}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def device_health_poller(stop_event: threading.Event, agent: Agent) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Background poller that monitors device availability and agent health.
|
|
57
|
+
Runs every 5 seconds to ensure a device is connected and the agent is healthy.
|
|
58
|
+
|
|
59
|
+
This is a sync wrapper that runs the async poller in a new event loop.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
stop_event: Threading event to signal when to stop polling.
|
|
63
|
+
agent: The Agent instance to monitor and reinitialize if needed.
|
|
64
|
+
"""
|
|
65
|
+
loop = None
|
|
66
|
+
try:
|
|
67
|
+
loop = asyncio.new_event_loop()
|
|
68
|
+
asyncio.set_event_loop(loop)
|
|
69
|
+
|
|
70
|
+
loop.run_until_complete(_async_device_health_poller(stop_event, agent))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error(f"Error in device health poller thread: {e}")
|
|
73
|
+
finally:
|
|
74
|
+
if loop is not None:
|
|
75
|
+
try:
|
|
76
|
+
loop.close()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
@@ -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
|