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.
Files changed (35) hide show
  1. minitap/mcp/__init__.py +0 -0
  2. minitap/mcp/core/agents/compare_screenshots/agent.py +75 -0
  3. minitap/mcp/core/agents/compare_screenshots/eval/prompts/prompt_1.md +62 -0
  4. minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/actual.png +0 -0
  5. minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/figma.png +0 -0
  6. minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/human_feedback.txt +18 -0
  7. minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/model_params.json +3 -0
  8. minitap/mcp/core/agents/compare_screenshots/eval/scenario_1_add_cartoon_img_and_move_button/prompt_1/output.md +46 -0
  9. minitap/mcp/core/agents/compare_screenshots/prompt.md +62 -0
  10. minitap/mcp/core/cloud_apk.py +117 -0
  11. minitap/mcp/core/config.py +111 -0
  12. minitap/mcp/core/decorators.py +107 -0
  13. minitap/mcp/core/device.py +249 -0
  14. minitap/mcp/core/llm.py +39 -0
  15. minitap/mcp/core/logging_config.py +59 -0
  16. minitap/mcp/core/models.py +59 -0
  17. minitap/mcp/core/sdk_agent.py +35 -0
  18. minitap/mcp/core/storage.py +407 -0
  19. minitap/mcp/core/task_runs.py +100 -0
  20. minitap/mcp/core/utils/figma.py +69 -0
  21. minitap/mcp/core/utils/images.py +55 -0
  22. minitap/mcp/main.py +328 -0
  23. minitap/mcp/server/cloud_mobile.py +492 -0
  24. minitap/mcp/server/middleware.py +21 -0
  25. minitap/mcp/server/poller.py +78 -0
  26. minitap/mcp/server/remote_proxy.py +96 -0
  27. minitap/mcp/tools/execute_mobile_command.py +182 -0
  28. minitap/mcp/tools/read_swift_logs.py +297 -0
  29. minitap/mcp/tools/screen_analyzer.md +17 -0
  30. minitap/mcp/tools/take_screenshot.py +53 -0
  31. minitap/mcp/tools/upload_screenshot.py +80 -0
  32. minitap_mcp-0.9.0.dist-info/METADATA +352 -0
  33. minitap_mcp-0.9.0.dist-info/RECORD +35 -0
  34. minitap_mcp-0.9.0.dist-info/WHEEL +4 -0
  35. minitap_mcp-0.9.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,249 @@
1
+ """Device detection and screenshot utilities for Android and iOS devices."""
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from adbutils import AdbClient, AdbDevice
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+ DevicePlatform = Literal["android", "ios"]
15
+
16
+
17
+ class MobileDevice(BaseModel):
18
+ """Represents a mobile device with its platform and connection details."""
19
+
20
+ model_config = ConfigDict(arbitrary_types_allowed=True)
21
+
22
+ device_id: str
23
+ platform: DevicePlatform
24
+ adb_device: AdbDevice | None = None # Only for Android
25
+
26
+
27
+ class DeviceInfo(BaseModel):
28
+ """Serializable device information."""
29
+
30
+ device_id: str
31
+ platform: DevicePlatform
32
+ name: str | None = None
33
+ state: str | None = None
34
+
35
+
36
+ class DeviceNotFoundError(Exception):
37
+ """Raised when no device can be found."""
38
+
39
+ pass
40
+
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
+
50
+ def get_adb_client() -> AdbClient:
51
+ """Get an ADB client instance."""
52
+ custom_adb_socket = os.getenv("ADB_SERVER_SOCKET")
53
+ if not custom_adb_socket:
54
+ return AdbClient()
55
+ parts = custom_adb_socket.split(":")
56
+ if len(parts) != 3:
57
+ raise ValueError(f"Invalid ADB server socket: {custom_adb_socket}")
58
+ _, host, port = parts
59
+ return AdbClient(host=host, port=int(port))
60
+
61
+
62
+ def list_available_devices() -> list[DeviceInfo]:
63
+ """
64
+ List all available mobile devices (Android and iOS).
65
+
66
+ Returns:
67
+ list[DeviceInfo]: A list of device information objects.
68
+ """
69
+ devices: list[DeviceInfo] = []
70
+
71
+ # List Android devices
72
+ try:
73
+ adb_client = get_adb_client()
74
+ android_devices = adb_client.device_list()
75
+
76
+ for device in android_devices:
77
+ if device.serial:
78
+ devices.append(
79
+ DeviceInfo(
80
+ device_id=device.serial,
81
+ platform="android",
82
+ name=device.serial,
83
+ state="connected",
84
+ )
85
+ )
86
+ except Exception:
87
+ # ADB not available or error listing devices
88
+ pass
89
+
90
+ # List iOS devices (only booted simulators to match SDK behavior)
91
+ try:
92
+ cmd = ["xcrun", "simctl", "list", "devices", "booted", "-j"]
93
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
94
+ data = json.loads(result.stdout)
95
+
96
+ for runtime, ios_devices in data.get("devices", {}).items():
97
+ if "iOS" not in runtime:
98
+ continue
99
+
100
+ for device in ios_devices:
101
+ udid = device.get("udid")
102
+ name = device.get("name")
103
+ state = device.get("state")
104
+
105
+ if udid and state == "Booted":
106
+ devices.append(
107
+ DeviceInfo(
108
+ device_id=udid,
109
+ platform="ios",
110
+ name=name,
111
+ state=state,
112
+ )
113
+ )
114
+ except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
115
+ # xcrun not available or error listing devices
116
+ pass
117
+
118
+ return devices
119
+
120
+
121
+ def find_mobile_device(device_id: str | None = None) -> MobileDevice:
122
+ """
123
+ Find a mobile device (Android via ADB or iOS via xcrun).
124
+
125
+ Args:
126
+ device_id: Optional device ID to target a specific device.
127
+ If None, returns the first available device.
128
+
129
+ Returns:
130
+ MobileDevice: A reference to the device with its platform information.
131
+
132
+ Raises:
133
+ DeviceNotFoundError: If no device is found or the specified device_id is not found.
134
+ """
135
+ # Get all available devices
136
+ available_devices = list_available_devices()
137
+
138
+ if not available_devices:
139
+ raise DeviceNotFoundError(
140
+ "No mobile device found. "
141
+ "Make sure you have an Android device connected via ADB "
142
+ "or an iOS simulator running."
143
+ )
144
+
145
+ # Find the target device
146
+ target_device = None
147
+ if device_id:
148
+ # Look for specific device
149
+ for dev in available_devices:
150
+ if dev.device_id == device_id:
151
+ target_device = dev
152
+ break
153
+ if not target_device:
154
+ raise DeviceNotFoundError(
155
+ f"Device with ID '{device_id}' not found. "
156
+ "Make sure the device is connected and accessible via adb or xcrun."
157
+ )
158
+ else:
159
+ # Prefer connected/booted devices first
160
+ for dev in available_devices:
161
+ if dev.state in ("connected", "Booted"):
162
+ target_device = dev
163
+ break
164
+ # Fall back to any device if no connected/booted device found
165
+ if not target_device:
166
+ target_device = available_devices[0]
167
+
168
+ # Create MobileDevice instance with platform-specific details
169
+ if target_device.platform == "android":
170
+ # For Android, get the AdbDevice reference
171
+ try:
172
+ adb_client = get_adb_client()
173
+ adb_device = adb_client.device(serial=target_device.device_id)
174
+ return MobileDevice(
175
+ device_id=target_device.device_id,
176
+ platform="android",
177
+ adb_device=adb_device,
178
+ )
179
+ except Exception as e:
180
+ raise DeviceNotFoundError(f"Failed to connect to Android device: {e}")
181
+ else:
182
+ # For iOS, just return the device info
183
+ return MobileDevice(device_id=target_device.device_id, platform="ios")
184
+
185
+
186
+ def capture_screenshot(device: MobileDevice) -> str:
187
+ """
188
+ Capture a screenshot from the given mobile device.
189
+
190
+ Args:
191
+ device: MobileDevice instance returned by find_mobile_device()
192
+
193
+ Returns:
194
+ str: Base64-encoded screenshot image (PNG format)
195
+
196
+ Raises:
197
+ RuntimeError: If screenshot capture fails
198
+ """
199
+ if device.platform == "android":
200
+ return _capture_android_screenshot(device)
201
+ else:
202
+ return _capture_ios_screenshot(device)
203
+
204
+
205
+ def _capture_android_screenshot(device: MobileDevice) -> str:
206
+ """Capture screenshot from Android device using ADB."""
207
+ if not device.adb_device:
208
+ # Reconnect to device if not available
209
+ adb_client = get_adb_client()
210
+ adb_device = adb_client.device(serial=device.device_id)
211
+ if not adb_device:
212
+ raise RuntimeError(f"Android device {device.device_id} not found")
213
+ device.adb_device = adb_device
214
+
215
+ try:
216
+ # Use ADB screencap to get PNG screenshot
217
+ screenshot_bytes = device.adb_device.shell("screencap -p", encoding=None)
218
+ if isinstance(screenshot_bytes, bytes):
219
+ return base64.b64encode(screenshot_bytes).decode("utf-8")
220
+ else:
221
+ raise RuntimeError("Unexpected screenshot data type from ADB")
222
+ except Exception as e:
223
+ raise RuntimeError(f"Failed to capture Android screenshot: {e}")
224
+
225
+
226
+ def _capture_ios_screenshot(device: MobileDevice) -> str:
227
+ """Capture screenshot from iOS simulator using xcrun."""
228
+ try:
229
+ # Create temporary file for screenshot
230
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
231
+ tmp_path = Path(tmp_file.name)
232
+
233
+ try:
234
+ # Capture screenshot using xcrun simctl
235
+ cmd = ["xcrun", "simctl", "io", device.device_id, "screenshot", str(tmp_path)]
236
+ subprocess.run(cmd, capture_output=True, text=True, check=True)
237
+
238
+ # Read and encode the screenshot
239
+ screenshot_bytes = tmp_path.read_bytes()
240
+ return base64.b64encode(screenshot_bytes).decode("utf-8")
241
+ finally:
242
+ # Clean up temporary file
243
+ if tmp_path.exists():
244
+ tmp_path.unlink()
245
+
246
+ except subprocess.CalledProcessError as e:
247
+ raise RuntimeError(f"Failed to capture iOS screenshot: {e.stderr}")
248
+ except Exception as e:
249
+ raise RuntimeError(f"Failed to capture iOS screenshot: {e}")
@@ -0,0 +1,39 @@
1
+ from langchain_openai import ChatOpenAI
2
+
3
+ from minitap.mcp.core.config import settings
4
+
5
+
6
+ def get_minitap_llm(
7
+ trace_id: str,
8
+ remote_tracing: bool = False,
9
+ model: str = "google/gemini-2.5-pro",
10
+ temperature: float | None = None,
11
+ max_retries: int | None = None,
12
+ ) -> ChatOpenAI:
13
+ assert settings.MINITAP_API_KEY is not None
14
+ assert settings.MINITAP_API_BASE_URL is not None
15
+ if max_retries is None and model.startswith("google/"):
16
+ max_retries = 2
17
+ client = ChatOpenAI(
18
+ model=model,
19
+ temperature=temperature,
20
+ max_retries=max_retries,
21
+ api_key=settings.MINITAP_API_KEY,
22
+ base_url=settings.MINITAP_API_BASE_URL,
23
+ default_query={
24
+ "sessionId": trace_id,
25
+ "traceOnlyUsage": remote_tracing,
26
+ },
27
+ )
28
+ return client
29
+
30
+
31
+ def get_openrouter_llm(model_name: str, temperature: float = 1):
32
+ assert settings.OPEN_ROUTER_API_KEY is not None
33
+ client = ChatOpenAI(
34
+ model=model_name,
35
+ temperature=temperature,
36
+ api_key=settings.OPEN_ROUTER_API_KEY,
37
+ base_url="https://openrouter.ai/api/v1",
38
+ )
39
+ return client
@@ -0,0 +1,59 @@
1
+ """Structured logging configuration using structlog."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import structlog
7
+
8
+
9
+ def configure_logging(log_level: str = "INFO") -> None:
10
+ """Configure structlog with sensible defaults for the MCP server.
11
+
12
+ Args:
13
+ log_level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
14
+ """
15
+ # Configure standard library logging
16
+ logging.basicConfig(
17
+ format="%(message)s",
18
+ stream=sys.stdout,
19
+ level=getattr(logging, log_level.upper()),
20
+ )
21
+
22
+ # Configure structlog
23
+ structlog.configure(
24
+ processors=[
25
+ # Add log level to event dict
26
+ structlog.stdlib.add_log_level,
27
+ # Add timestamp
28
+ structlog.processors.TimeStamper(fmt="iso"),
29
+ # Add caller information (file, line, function)
30
+ structlog.processors.CallsiteParameterAdder(
31
+ parameters=[
32
+ structlog.processors.CallsiteParameter.FILENAME,
33
+ structlog.processors.CallsiteParameter.LINENO,
34
+ structlog.processors.CallsiteParameter.FUNC_NAME,
35
+ ]
36
+ ),
37
+ # Stack info and exception formatting
38
+ structlog.processors.StackInfoRenderer(),
39
+ structlog.processors.format_exc_info,
40
+ # Render as JSON for structured output
41
+ structlog.processors.JSONRenderer(),
42
+ ],
43
+ wrapper_class=structlog.stdlib.BoundLogger,
44
+ context_class=dict,
45
+ logger_factory=structlog.stdlib.LoggerFactory(),
46
+ cache_logger_on_first_use=True,
47
+ )
48
+
49
+
50
+ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
51
+ """Get a structured logger instance.
52
+
53
+ Args:
54
+ name: The logger name (typically __name__ of the module)
55
+
56
+ Returns:
57
+ A structlog BoundLogger instance
58
+ """
59
+ return structlog.get_logger(name)
@@ -0,0 +1,59 @@
1
+ """Core models for the MCP server."""
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class FigmaAsset(BaseModel):
9
+ """Represents a single Figma asset."""
10
+
11
+ variable_name: str = Field(description="The variable name from the code (e.g., imgSignal)")
12
+ url: str = Field(description="The full URL to the asset")
13
+ extension: str = Field(description="The file extension (e.g., svg, png, jpg)")
14
+
15
+
16
+ class FigmaDesignContextOutput(BaseModel):
17
+ """Output from Figma design context containing code and guidelines."""
18
+
19
+ code_implementation: str = Field(description="The React/TypeScript code implementation")
20
+ code_implementation_guidelines: str | None = Field(
21
+ default=None, description="Guidelines for implementing the code"
22
+ )
23
+ nodes_guidelines: str | None = Field(
24
+ default=None, description="Guidelines specific to the nodes"
25
+ )
26
+
27
+
28
+ class DownloadStatus(str, Enum):
29
+ """Status of asset download operation."""
30
+
31
+ SUCCESS = "success"
32
+ FAILED = "failed"
33
+
34
+
35
+ class AssetDownloadResult(BaseModel):
36
+ """Result of downloading a single asset."""
37
+
38
+ filename: str = Field(description="The filename of the asset")
39
+ status: DownloadStatus = Field(description="The download status")
40
+ error: str | None = Field(default=None, description="Error message if download failed")
41
+
42
+
43
+ class AssetDownloadSummary(BaseModel):
44
+ """Summary of all asset download operations."""
45
+
46
+ successful: list[AssetDownloadResult] = Field(
47
+ default_factory=list, description="List of successfully downloaded assets"
48
+ )
49
+ failed: list[AssetDownloadResult] = Field(
50
+ default_factory=list, description="List of failed asset downloads"
51
+ )
52
+
53
+ def success_count(self) -> int:
54
+ """Return the number of successful downloads."""
55
+ return len(self.successful)
56
+
57
+ def failure_count(self) -> int:
58
+ """Return the number of failed downloads."""
59
+ return len(self.failed)
@@ -0,0 +1,35 @@
1
+ import os
2
+
3
+ from minitap.mobile_use.sdk import Agent
4
+ from minitap.mobile_use.sdk.builders import Builders
5
+
6
+ from minitap.mcp.core.config import settings
7
+
8
+ # Lazy-initialized singleton agent
9
+ _agent: Agent | None = None
10
+
11
+
12
+ def get_mobile_use_agent() -> Agent:
13
+ """Get or create the mobile-use agent singleton.
14
+
15
+ This function lazily initializes the agent on first call, ensuring
16
+ that CLI arguments are parsed before agent creation.
17
+ """
18
+ global _agent
19
+ if _agent is None:
20
+ config = Builders.AgentConfig
21
+ custom_adb_socket = os.getenv("ADB_SERVER_SOCKET")
22
+ if custom_adb_socket:
23
+ parts = custom_adb_socket.split(":")
24
+ if len(parts) != 3:
25
+ raise ValueError(f"Invalid ADB server socket: {custom_adb_socket}")
26
+ _, host, port = parts
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
+
33
+ _agent = Agent(config=config.build())
34
+
35
+ return _agent