minitap-mcp 0.5.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/PKG-INFO +57 -4
  2. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/PYPI_README.md +54 -2
  3. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/agent.py +12 -2
  4. minitap_mcp-0.6.0/minitap/mcp/core/cloud_apk.py +109 -0
  5. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/config.py +7 -0
  6. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/sdk_agent.py +8 -0
  7. minitap_mcp-0.6.0/minitap/mcp/main.py +276 -0
  8. minitap_mcp-0.6.0/minitap/mcp/server/cloud_mobile.py +397 -0
  9. minitap_mcp-0.6.0/minitap/mcp/server/middleware.py +21 -0
  10. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/server/poller.py +6 -6
  11. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/tools/analyze_screen.py +14 -3
  12. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/tools/compare_screenshot_with_figma.py +1 -1
  13. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/tools/execute_mobile_command.py +47 -8
  14. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/pyproject.toml +3 -2
  15. minitap_mcp-0.5.2/minitap/mcp/main.py +0 -150
  16. minitap_mcp-0.5.2/minitap/mcp/server/middleware.py +0 -23
  17. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/__init__.py +0 -0
  18. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/eval/prompts/prompt_1.md +0 -0
  19. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/actual.png +0 -0
  20. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/figma.png +0 -0
  21. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/human_feedback.txt +0 -0
  22. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/model_params.json +0 -0
  23. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/output.md +0 -0
  24. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/agents/compare_screenshots/prompt.md +0 -0
  25. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/decorators.py +0 -0
  26. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/device.py +0 -0
  27. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/llm.py +0 -0
  28. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/logging_config.py +0 -0
  29. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/models.py +0 -0
  30. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/utils/figma.py +0 -0
  31. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/core/utils/images.py +0 -0
  32. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/tools/save_figma_assets.py +0 -0
  33. {minitap_mcp-0.5.2 → minitap_mcp-0.6.0}/minitap/mcp/tools/screen_analyzer.md +0 -0
@@ -1,17 +1,18 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: minitap-mcp
3
- Version: 0.5.2
3
+ Version: 0.6.0
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>=2.9.2
10
+ Requires-Dist: minitap-mobile-use>=3.0.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
14
14
  Requires-Dist: structlog>=24.4.0
15
+ Requires-Dist: aiohttp>=3.9.0
15
16
  Requires-Dist: ruff==0.5.3 ; extra == 'dev'
16
17
  Requires-Dist: pytest==8.4.1 ; extra == 'dev'
17
18
  Requires-Dist: pytest-cov==5.0.0 ; extra == 'dev'
@@ -40,11 +41,19 @@ Before running the MCP server, ensure you have the required mobile automation to
40
41
  - **For Android devices:**
41
42
 
42
43
  - [ADB (Android Debug Bridge)](https://developer.android.com/tools/adb) - For device communication
43
- - [Maestro](https://maestro.mobile.dev/) - For mobile automation
44
44
 
45
45
  - **For iOS devices (macOS only):**
46
46
  - Xcode Command Line Tools with `xcrun`
47
- - [Maestro](https://maestro.mobile.dev/) - For mobile automation
47
+ - **[fb-idb](https://fbidb.io/docs/installation/)**: Facebook's iOS Development Bridge for device automation.
48
+
49
+ ```bash
50
+ # Install via Homebrew (macOS)
51
+ brew tap facebook/fb
52
+ brew install idb-companion
53
+ ```
54
+
55
+ > [!NOTE]
56
+ > `idb_companion` is required to communicate with iOS simulators. Make sure it's in your PATH after installation.
48
57
 
49
58
  For detailed setup instructions, see the [mobile-use repository](https://github.com/minitap-ai/mobile-use).
50
59
 
@@ -244,6 +253,50 @@ The tool will:
244
253
  3. Compare both screenshots using vision AI
245
254
  4. Return a detailed analysis highlighting differences
246
255
 
256
+ ## Cloud Mobile Mode
257
+
258
+ Run the MCP server with cloud-hosted mobile devices instead of requiring a local device. This enables:
259
+
260
+ - **Zero local setup**: No ADB or physical device required
261
+ - **Remote development**: Control cloud mobiles from anywhere
262
+ - **Scalable automation**: Access multiple cloud devices
263
+
264
+ ### Setting Up Cloud Mobile Mode
265
+
266
+ 1. **Create a Cloud Mobile** on [Minitap Platform](https://platform.minitap.ai/cloud-mobiles):
267
+ - Click **Create New Device**
268
+ - Choose platform (currently Android v11 / API level 30)
269
+ - Set a **Reference Name** (e.g., `my-dev-device`)
270
+
271
+ 2. **Configure the environment variable**:
272
+
273
+ ```bash
274
+ # Using reference name (recommended)
275
+ export CLOUD_MOBILE_NAME="my-dev-device"
276
+ ```
277
+
278
+ 3. **Start the server** (no local device needed):
279
+
280
+ ```bash
281
+ minitap-mcp --server --api-key your_minitap_api_key
282
+ ```
283
+
284
+ The server will:
285
+ - Connect to your cloud mobile on startup
286
+ - Maintain a keep-alive connection while running
287
+ - Automatically disconnect when the server stops
288
+
289
+ > ⚠️ **Important**: Cloud mobiles are billed while connected. The MCP server automatically stops the connection when you close your IDE or stop the server. Make sure to properly shut down the server to avoid unexpected charges.
290
+
291
+ ### Cloud vs Local Mode
292
+
293
+ | Feature | Local Mode | Cloud Mode |
294
+ |---------|------------|------------|
295
+ | Device requirement | Physical/emulator | None |
296
+ | Setup complexity | ADB setup required | Low (env var only) |
297
+ | `CLOUD_MOBILE_NAME` | Not set | Set to device name/UUID |
298
+ | Billing | None | Per-minute usage |
299
+
247
300
  ## Advanced Configuration
248
301
 
249
302
  ### Custom ADB Server
@@ -17,11 +17,19 @@ Before running the MCP server, ensure you have the required mobile automation to
17
17
  - **For Android devices:**
18
18
 
19
19
  - [ADB (Android Debug Bridge)](https://developer.android.com/tools/adb) - For device communication
20
- - [Maestro](https://maestro.mobile.dev/) - For mobile automation
21
20
 
22
21
  - **For iOS devices (macOS only):**
23
22
  - Xcode Command Line Tools with `xcrun`
24
- - [Maestro](https://maestro.mobile.dev/) - For mobile automation
23
+ - **[fb-idb](https://fbidb.io/docs/installation/)**: Facebook's iOS Development Bridge for device automation.
24
+
25
+ ```bash
26
+ # Install via Homebrew (macOS)
27
+ brew tap facebook/fb
28
+ brew install idb-companion
29
+ ```
30
+
31
+ > [!NOTE]
32
+ > `idb_companion` is required to communicate with iOS simulators. Make sure it's in your PATH after installation.
25
33
 
26
34
  For detailed setup instructions, see the [mobile-use repository](https://github.com/minitap-ai/mobile-use).
27
35
 
@@ -221,6 +229,50 @@ The tool will:
221
229
  3. Compare both screenshots using vision AI
222
230
  4. Return a detailed analysis highlighting differences
223
231
 
232
+ ## Cloud Mobile Mode
233
+
234
+ Run the MCP server with cloud-hosted mobile devices instead of requiring a local device. This enables:
235
+
236
+ - **Zero local setup**: No ADB or physical device required
237
+ - **Remote development**: Control cloud mobiles from anywhere
238
+ - **Scalable automation**: Access multiple cloud devices
239
+
240
+ ### Setting Up Cloud Mobile Mode
241
+
242
+ 1. **Create a Cloud Mobile** on [Minitap Platform](https://platform.minitap.ai/cloud-mobiles):
243
+ - Click **Create New Device**
244
+ - Choose platform (currently Android v11 / API level 30)
245
+ - Set a **Reference Name** (e.g., `my-dev-device`)
246
+
247
+ 2. **Configure the environment variable**:
248
+
249
+ ```bash
250
+ # Using reference name (recommended)
251
+ export CLOUD_MOBILE_NAME="my-dev-device"
252
+ ```
253
+
254
+ 3. **Start the server** (no local device needed):
255
+
256
+ ```bash
257
+ minitap-mcp --server --api-key your_minitap_api_key
258
+ ```
259
+
260
+ The server will:
261
+ - Connect to your cloud mobile on startup
262
+ - Maintain a keep-alive connection while running
263
+ - Automatically disconnect when the server stops
264
+
265
+ > ⚠️ **Important**: Cloud mobiles are billed while connected. The MCP server automatically stops the connection when you close your IDE or stop the server. Make sure to properly shut down the server to avoid unexpected charges.
266
+
267
+ ### Cloud vs Local Mode
268
+
269
+ | Feature | Local Mode | Cloud Mode |
270
+ |---------|------------|------------|
271
+ | Device requirement | Physical/emulator | None |
272
+ | Setup complexity | ADB setup required | Low (env var only) |
273
+ | `CLOUD_MOBILE_NAME` | Not set | Set to device name/UUID |
274
+ | Billing | None | Per-minute usage |
275
+
224
276
  ## Advanced Configuration
225
277
 
226
278
  ### Custom ADB Server
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import base64
2
3
  from pathlib import Path
3
4
  from uuid import uuid4
4
5
 
@@ -9,6 +10,7 @@ from pydantic import BaseModel
9
10
  from minitap.mcp.core.device import capture_screenshot, find_mobile_device
10
11
  from minitap.mcp.core.llm import get_minitap_llm
11
12
  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
12
14
 
13
15
 
14
16
  class CompareScreenshotsOutput(BaseModel):
@@ -23,6 +25,8 @@ async def compare_screenshots(
23
25
  """
24
26
  Compare screenshots and return the comparison text along with both screenshots.
25
27
 
28
+ Supports both local devices (Android/iOS) and cloud devices.
29
+
26
30
  Returns:
27
31
  CompareScreenshotsOutput
28
32
  """
@@ -30,8 +34,14 @@ async def compare_screenshots(
30
34
  Path(__file__).parent.joinpath("prompt.md").read_text(encoding="utf-8")
31
35
  ).render()
32
36
 
33
- device = find_mobile_device()
34
- current_screenshot = capture_screenshot(device)
37
+ cloud_mobile_id = get_cloud_mobile_id()
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
45
 
36
46
  messages: list[BaseMessage] = [
37
47
  SystemMessage(content=system_message),
@@ -0,0 +1,109 @@
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
+ filename = f"app_{uuid.uuid4().hex[:6]}.apk"
36
+ api_key = settings.MINITAP_API_KEY.get_secret_value()
37
+ api_base_url = settings.MINITAP_API_BASE_URL.rstrip("/")
38
+
39
+ async with httpx.AsyncClient(timeout=300.0) as client:
40
+ # Step 1: Get signed upload URL from storage API
41
+ response = await client.get(
42
+ f"{api_base_url}/storage/signed-upload",
43
+ headers={"Authorization": f"Bearer {api_key}"},
44
+ params={"filenames": filename},
45
+ )
46
+ response.raise_for_status()
47
+ upload_data = response.json()
48
+
49
+ # Extract the signed URL for our file
50
+ signed_urls = upload_data.get("signed_urls", {})
51
+ if filename not in signed_urls:
52
+ raise ValueError(f"No signed URL returned for {filename}")
53
+
54
+ signed_url = signed_urls[filename]
55
+
56
+ # Step 2: Upload APK to signed URL
57
+ with open(apk_file, "rb") as f:
58
+ upload_response = await client.put(
59
+ signed_url,
60
+ content=f.read(),
61
+ headers={"Content-Type": "application/vnd.android.package-archive"},
62
+ )
63
+ upload_response.raise_for_status()
64
+
65
+ # Step 3: Return filename for install-apk call
66
+ return filename
67
+
68
+
69
+ async def install_apk_on_cloud_mobile(filename: str) -> None:
70
+ """
71
+ Install an APK on a cloud mobile device via mobile-manager API.
72
+
73
+ Args:
74
+ filename: Filename returned from upload_apk_to_cloud_mobile
75
+
76
+ Raises:
77
+ httpx.HTTPError: If installation fails
78
+ ValueError: If required config settings are not configured
79
+ """
80
+ if not settings.MINITAP_API_KEY:
81
+ raise ValueError("MINITAP_API_KEY is not configured")
82
+ if not settings.MINITAP_DAAS_API:
83
+ raise ValueError("MINITAP_DAAS_API is not configured")
84
+ if not settings.CLOUD_MOBILE_NAME:
85
+ raise ValueError("CLOUD_MOBILE_NAME is not configured")
86
+
87
+ api_key = settings.MINITAP_API_KEY.get_secret_value()
88
+ base_url = settings.MINITAP_DAAS_API
89
+ cloud_mobile_name = settings.CLOUD_MOBILE_NAME
90
+
91
+ async with httpx.AsyncClient(timeout=120.0) as client:
92
+ cloud_mobile_response = await client.get(
93
+ f"{base_url}/virtual-mobiles/{cloud_mobile_name}",
94
+ headers={"Authorization": f"Bearer {api_key}"},
95
+ )
96
+ cloud_mobile_response.raise_for_status()
97
+ response_data = cloud_mobile_response.json()
98
+ cloud_mobile_uuid = response_data.get("id")
99
+ if not cloud_mobile_uuid:
100
+ raise ValueError(f"Cloud mobile '{cloud_mobile_name}' response missing 'id' field")
101
+ response = await client.post(
102
+ f"{base_url}/virtual-mobiles/{cloud_mobile_uuid}/install-apk",
103
+ headers={
104
+ "Authorization": f"Bearer {api_key}",
105
+ "Content-Type": "application/json",
106
+ },
107
+ json={"filename": filename},
108
+ )
109
+ response.raise_for_status()
@@ -16,6 +16,7 @@ class MCPSettings(BaseSettings):
16
16
  # Minitap API configuration
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
+ MINITAP_DAAS_API: str = Field(default="https://platform.minitap.ai/api/daas")
19
20
  OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
20
21
 
21
22
  VISION_MODEL: str = Field(default="qwen/qwen-2.5-vl-7b-instruct")
@@ -27,5 +28,11 @@ class MCPSettings(BaseSettings):
27
28
  MCP_SERVER_HOST: str = Field(default="0.0.0.0")
28
29
  MCP_SERVER_PORT: int = Field(default=8000)
29
30
 
31
+ # Cloud Mobile configuration
32
+ # When set, the MCP server runs in cloud mode connecting to a Minitap cloud mobile
33
+ # instead of requiring a local device. Value can be a device name.
34
+ # Create cloud mobiles at https://platform.minitap.ai/cloud-mobiles
35
+ CLOUD_MOBILE_NAME: str | None = Field(default=None)
36
+
30
37
 
31
38
  settings = MCPSettings() # type: ignore
@@ -3,6 +3,8 @@ 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
+
6
8
  # Lazy-initialized singleton agent
7
9
  _agent: Agent | None = None
8
10
 
@@ -23,5 +25,11 @@ def get_mobile_use_agent() -> Agent:
23
25
  raise ValueError(f"Invalid ADB server socket: {custom_adb_socket}")
24
26
  _, host, port = parts
25
27
  config = config.with_adb_server(host=host, port=int(port))
28
+
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
+
26
33
  _agent = Agent(config=config.build())
34
+
27
35
  return _agent
@@ -0,0 +1,276 @@
1
+ """MCP server for mobile-use with screen analysis capabilities."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ import threading
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+
11
+ # Fix Windows console encoding for Unicode characters (emojis in logs)
12
+ if sys.platform == "win32":
13
+ if hasattr(sys.stdout, "reconfigure"):
14
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
15
+ if hasattr(sys.stderr, "reconfigure"):
16
+ sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
17
+ os.environ["PYTHONIOENCODING"] = "utf-8"
18
+
19
+ try:
20
+ import colorama
21
+
22
+ colorama.init(strip=False, convert=True, wrap=True)
23
+ except ImportError:
24
+ pass
25
+
26
+
27
+ from fastmcp import FastMCP # noqa: E402
28
+ from minitap.mobile_use.config import settings as sdk_settings
29
+
30
+ from minitap.mcp.core.config import settings # noqa: E402
31
+ from minitap.mcp.core.device import (
32
+ DeviceInfo, # noqa: E402
33
+ list_available_devices,
34
+ )
35
+ from minitap.mcp.core.logging_config import (
36
+ configure_logging, # noqa: E402
37
+ get_logger,
38
+ )
39
+ from minitap.mcp.server.cloud_mobile import CloudMobileService
40
+ from minitap.mcp.server.middleware import LocalDeviceHealthMiddleware
41
+ from minitap.mcp.server.poller import device_health_poller
42
+
43
+ configure_logging(log_level=os.getenv("LOG_LEVEL", "INFO"))
44
+
45
+
46
+ def main() -> None:
47
+ """Main entry point for the MCP server."""
48
+
49
+ parser = argparse.ArgumentParser(description="Mobile Use MCP Server")
50
+ parser.add_argument(
51
+ "--api-key",
52
+ type=str,
53
+ required=False,
54
+ default=None,
55
+ help="Minitap API key for authentication",
56
+ )
57
+ parser.add_argument(
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
+ )
64
+ parser.add_argument("--llm-profile", type=str, required=False, default=None)
65
+ parser.add_argument(
66
+ "--server",
67
+ action="store_true",
68
+ help="Run as network server (uses MCP_SERVER_HOST and MCP_SERVER_PORT from env)",
69
+ )
70
+ parser.add_argument(
71
+ "--port",
72
+ type=int,
73
+ required=False,
74
+ default=None,
75
+ help="Port to run the server on (overrides MCP_SERVER_PORT env variable)",
76
+ )
77
+
78
+ args = parser.parse_args()
79
+
80
+ if args.api_key:
81
+ os.environ["MINITAP_API_KEY"] = args.api_key
82
+ settings.__init__()
83
+ sdk_settings.__init__()
84
+
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
+ if args.llm_profile:
91
+ os.environ["MINITAP_LLM_PROFILE_NAME"] = args.llm_profile
92
+ settings.__init__()
93
+ sdk_settings.__init__()
94
+
95
+ if args.port:
96
+ os.environ["MCP_SERVER_PORT"] = str(args.port)
97
+ settings.__init__()
98
+ sdk_settings.__init__()
99
+
100
+ if not settings.MINITAP_API_KEY:
101
+ raise ValueError("Minitap API key is required to run the MCP")
102
+
103
+ # Run MCP server with optional host/port for remote access
104
+ if args.server:
105
+ logger.info(f"Starting MCP server on {settings.MCP_SERVER_HOST}:{settings.MCP_SERVER_PORT}")
106
+ run_mcp_server(
107
+ transport="http",
108
+ host=settings.MCP_SERVER_HOST,
109
+ port=settings.MCP_SERVER_PORT,
110
+ )
111
+ else:
112
+ logger.info("Starting MCP server in local mode")
113
+ run_mcp_server()
114
+
115
+
116
+ logger = get_logger(__name__)
117
+
118
+
119
+ @dataclass
120
+ class MCPLifespanContext:
121
+ """Context for MCP server lifespan.
122
+
123
+ Stores references to services that need cleanup on shutdown.
124
+ """
125
+
126
+ cloud_mobile_service: CloudMobileService | None = None
127
+ local_poller_stop_event: threading.Event | None = None
128
+ local_poller_thread: threading.Thread | None = None
129
+
130
+
131
+ @asynccontextmanager
132
+ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
133
+ """Lifespan context manager for MCP server.
134
+
135
+ Handles startup/shutdown for both cloud and local modes.
136
+
137
+ Cloud mode (CLOUD_MOBILE_NAME set):
138
+ - Connects to cloud mobile on startup
139
+ - Maintains keep-alive polling
140
+ - Disconnects on shutdown (critical for billing!)
141
+
142
+ Local mode (CLOUD_MOBILE_NAME not set):
143
+ - Starts device health poller
144
+ - Monitors local device connection
145
+
146
+ Args:
147
+ server: The FastMCP server instance.
148
+
149
+ Yields:
150
+ MCPLifespanContext with references to running services.
151
+
152
+ Raises:
153
+ RuntimeError: If cloud mobile connection fails
154
+ (crashes MCP to prevent false "connected" state).
155
+ """
156
+ from minitap.mcp.core.sdk_agent import get_mobile_use_agent # noqa: E402
157
+
158
+ context = MCPLifespanContext()
159
+ api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
160
+
161
+ # Check if running in cloud mode
162
+ if settings.CLOUD_MOBILE_NAME:
163
+ # ==================== CLOUD MODE ====================
164
+ logger.info(f"Starting MCP in CLOUD mode with mobile: {settings.CLOUD_MOBILE_NAME}")
165
+
166
+ if not api_key:
167
+ logger.error("MINITAP_API_KEY is required")
168
+ raise RuntimeError(
169
+ "MINITAP_API_KEY is required when CLOUD_MOBILE_NAME is set. "
170
+ "Please set your API key via --api-key or MINITAP_API_KEY environment variable."
171
+ )
172
+
173
+ # Create and connect cloud mobile service
174
+ cloud_service = CloudMobileService(
175
+ cloud_mobile_name=settings.CLOUD_MOBILE_NAME,
176
+ api_key=api_key,
177
+ )
178
+
179
+ try:
180
+ await cloud_service.connect()
181
+ except Exception as e:
182
+ # CRITICAL: If cloud mobile not found, crash the MCP!
183
+ # This prevents the IDE from showing the server as "connected"
184
+ # when it actually can't do anything useful.
185
+ logger.error(f"Failed to connect to cloud mobile: {e}")
186
+ raise RuntimeError(
187
+ f"Cloud mobile connection failed. The MCP server cannot start.\n{e}"
188
+ ) from e
189
+
190
+ context.cloud_mobile_service = cloud_service
191
+ logger.info("Cloud mobile connected, MCP server ready")
192
+
193
+ else:
194
+ # ==================== LOCAL MODE ====================
195
+ logger.info("Starting MCP in LOCAL mode (no CLOUD_MOBILE_NAME set)")
196
+
197
+ agent = get_mobile_use_agent()
198
+ server.add_middleware(LocalDeviceHealthMiddleware(agent))
199
+
200
+ # Start device health poller in background
201
+ stop_event = threading.Event()
202
+ poller_thread = threading.Thread(
203
+ target=device_health_poller,
204
+ args=(stop_event, agent),
205
+ daemon=True,
206
+ )
207
+ poller_thread.start()
208
+
209
+ context.local_poller_stop_event = stop_event
210
+ context.local_poller_thread = poller_thread
211
+ logger.info("Device health poller started")
212
+
213
+ try:
214
+ yield context
215
+ finally:
216
+ # ==================== SHUTDOWN ====================
217
+ logger.info("MCP server shutting down, cleaning up resources...")
218
+
219
+ if context.cloud_mobile_service:
220
+ # CRITICAL: Stop cloud mobile connection to stop billing!
221
+ logger.info("Disconnecting cloud mobile (stopping billing)...")
222
+ try:
223
+ await context.cloud_mobile_service.disconnect()
224
+ logger.info("Cloud mobile disconnected successfully")
225
+ except Exception as e:
226
+ logger.error(f"Error disconnecting cloud mobile: {e}")
227
+
228
+ if context.local_poller_stop_event and context.local_poller_thread:
229
+ # Stop local device health poller
230
+ logger.info("Stopping device health poller...")
231
+ context.local_poller_stop_event.set()
232
+ context.local_poller_thread.join(timeout=10.0)
233
+
234
+ if context.local_poller_thread.is_alive():
235
+ logger.warning("Device health poller thread did not stop gracefully")
236
+ else:
237
+ logger.info("Device health poller stopped successfully")
238
+
239
+ logger.info("MCP server shutdown complete")
240
+
241
+
242
+ # Create MCP server with lifespan handler
243
+ mcp = FastMCP(
244
+ name="mobile-use-mcp",
245
+ instructions="""
246
+ This server provides analysis tools for connected
247
+ mobile devices (iOS or Android).
248
+ Call get_available_devices() to list them.
249
+ """,
250
+ lifespan=mcp_lifespan,
251
+ )
252
+
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
257
+
258
+
259
+ @mcp.resource("data://devices")
260
+ def get_available_devices() -> list[DeviceInfo]:
261
+ """Provides a list of connected mobile devices (iOS or Android)."""
262
+ return list_available_devices()
263
+
264
+
265
+ def run_mcp_server(**mcp_run_kwargs):
266
+ """Run the MCP server with proper exception handling.
267
+
268
+ This wraps mcp.run() with exception handling for clean shutdown.
269
+ """
270
+ try:
271
+ mcp.run(**mcp_run_kwargs)
272
+ except KeyboardInterrupt:
273
+ logger.info("Keyboard interrupt received, shutting down...")
274
+ except Exception as e:
275
+ logger.error(f"Error running MCP server: {e}")
276
+ raise