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
minitap/mcp/main.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
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
|
+
|
|
29
|
+
from minitap.mcp.core.config import settings # noqa: E402
|
|
30
|
+
from minitap.mcp.core.device import (
|
|
31
|
+
DeviceInfo, # noqa: E402
|
|
32
|
+
list_available_devices,
|
|
33
|
+
)
|
|
34
|
+
from minitap.mcp.core.logging_config import (
|
|
35
|
+
configure_logging, # noqa: E402
|
|
36
|
+
get_logger,
|
|
37
|
+
)
|
|
38
|
+
from minitap.mcp.server.cloud_mobile import CloudMobileService
|
|
39
|
+
from minitap.mcp.server.middleware import LocalDeviceHealthMiddleware
|
|
40
|
+
from minitap.mcp.server.poller import device_health_poller
|
|
41
|
+
from minitap.mobile_use.config import settings as sdk_settings
|
|
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
|
+
remote_mcp_proxy: FastMCP | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
|
|
134
|
+
"""Lifespan context manager for MCP server.
|
|
135
|
+
|
|
136
|
+
Handles startup/shutdown for both cloud and local modes.
|
|
137
|
+
|
|
138
|
+
Cloud mode (CLOUD_MOBILE_NAME set):
|
|
139
|
+
- Connects to cloud mobile on startup
|
|
140
|
+
- Maintains keep-alive polling
|
|
141
|
+
- Disconnects on shutdown (critical for billing!)
|
|
142
|
+
|
|
143
|
+
Local mode (CLOUD_MOBILE_NAME not set):
|
|
144
|
+
- Starts device health poller
|
|
145
|
+
- Monitors local device connection
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
server: The FastMCP server instance.
|
|
149
|
+
|
|
150
|
+
Yields:
|
|
151
|
+
MCPLifespanContext with references to running services.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
RuntimeError: If cloud mobile connection fails
|
|
155
|
+
(crashes MCP to prevent false "connected" state).
|
|
156
|
+
"""
|
|
157
|
+
from minitap.mcp.core.sdk_agent import get_mobile_use_agent # noqa: E402
|
|
158
|
+
|
|
159
|
+
context = MCPLifespanContext()
|
|
160
|
+
api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
|
|
161
|
+
|
|
162
|
+
# Check if running in cloud mode
|
|
163
|
+
if settings.CLOUD_MOBILE_NAME:
|
|
164
|
+
# ==================== CLOUD MODE ====================
|
|
165
|
+
logger.info(f"Starting MCP in CLOUD mode with mobile: {settings.CLOUD_MOBILE_NAME}")
|
|
166
|
+
|
|
167
|
+
if not api_key:
|
|
168
|
+
logger.error("MINITAP_API_KEY is required")
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"MINITAP_API_KEY is required when CLOUD_MOBILE_NAME is set. "
|
|
171
|
+
"Please set your API key via --api-key or MINITAP_API_KEY environment variable."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Create and connect cloud mobile service
|
|
175
|
+
cloud_service = CloudMobileService(
|
|
176
|
+
cloud_mobile_name=settings.CLOUD_MOBILE_NAME,
|
|
177
|
+
api_key=api_key,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
await cloud_service.connect()
|
|
182
|
+
except Exception as e:
|
|
183
|
+
# CRITICAL: If cloud mobile not found, crash the MCP!
|
|
184
|
+
# This prevents the IDE from showing the server as "connected"
|
|
185
|
+
# when it actually can't do anything useful.
|
|
186
|
+
logger.error(f"Failed to connect to cloud mobile: {e}")
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"Cloud mobile connection failed. The MCP server cannot start.\n{e}"
|
|
189
|
+
) from e
|
|
190
|
+
|
|
191
|
+
context.cloud_mobile_service = cloud_service
|
|
192
|
+
logger.info("Cloud mobile connected, MCP server ready")
|
|
193
|
+
|
|
194
|
+
else:
|
|
195
|
+
# ==================== LOCAL MODE ====================
|
|
196
|
+
logger.info("Starting MCP in LOCAL mode (no CLOUD_MOBILE_NAME set)")
|
|
197
|
+
|
|
198
|
+
agent = get_mobile_use_agent()
|
|
199
|
+
server.add_middleware(LocalDeviceHealthMiddleware(agent))
|
|
200
|
+
|
|
201
|
+
# Start device health poller in background
|
|
202
|
+
stop_event = threading.Event()
|
|
203
|
+
poller_thread = threading.Thread(
|
|
204
|
+
target=device_health_poller,
|
|
205
|
+
args=(stop_event, agent),
|
|
206
|
+
daemon=True,
|
|
207
|
+
)
|
|
208
|
+
poller_thread.start()
|
|
209
|
+
|
|
210
|
+
context.local_poller_stop_event = stop_event
|
|
211
|
+
context.local_poller_thread = poller_thread
|
|
212
|
+
logger.info("Device health poller started")
|
|
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
|
+
|
|
254
|
+
try:
|
|
255
|
+
yield context
|
|
256
|
+
finally:
|
|
257
|
+
# ==================== SHUTDOWN ====================
|
|
258
|
+
logger.info("MCP server shutting down, cleaning up resources...")
|
|
259
|
+
|
|
260
|
+
if context.cloud_mobile_service:
|
|
261
|
+
# CRITICAL: Stop cloud mobile connection to stop billing!
|
|
262
|
+
logger.info("Disconnecting cloud mobile (stopping billing)...")
|
|
263
|
+
try:
|
|
264
|
+
await context.cloud_mobile_service.disconnect()
|
|
265
|
+
logger.info("Cloud mobile disconnected successfully")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error(f"Error disconnecting cloud mobile: {e}")
|
|
268
|
+
|
|
269
|
+
if context.local_poller_stop_event and context.local_poller_thread:
|
|
270
|
+
# Stop local device health poller
|
|
271
|
+
logger.info("Stopping device health poller...")
|
|
272
|
+
context.local_poller_stop_event.set()
|
|
273
|
+
context.local_poller_thread.join(timeout=10.0)
|
|
274
|
+
|
|
275
|
+
if context.local_poller_thread.is_alive():
|
|
276
|
+
logger.warning("Device health poller thread did not stop gracefully")
|
|
277
|
+
else:
|
|
278
|
+
logger.info("Device health poller stopped successfully")
|
|
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
|
+
|
|
290
|
+
logger.info("MCP server shutdown complete")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Create MCP server with lifespan handler
|
|
294
|
+
mcp = FastMCP(
|
|
295
|
+
name="mobile-use-mcp",
|
|
296
|
+
instructions="""
|
|
297
|
+
This server provides analysis tools for connected
|
|
298
|
+
mobile devices (iOS or Android).
|
|
299
|
+
Call get_available_devices() to list them.
|
|
300
|
+
""",
|
|
301
|
+
lifespan=mcp_lifespan,
|
|
302
|
+
)
|
|
303
|
+
|
|
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
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@mcp.resource("data://devices")
|
|
312
|
+
def get_available_devices() -> list[DeviceInfo]:
|
|
313
|
+
"""Provides a list of connected mobile devices (iOS or Android)."""
|
|
314
|
+
return list_available_devices()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def run_mcp_server(**mcp_run_kwargs):
|
|
318
|
+
"""Run the MCP server with proper exception handling.
|
|
319
|
+
|
|
320
|
+
This wraps mcp.run() with exception handling for clean shutdown.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
mcp.run(**mcp_run_kwargs)
|
|
324
|
+
except KeyboardInterrupt:
|
|
325
|
+
logger.info("Keyboard interrupt received, shutting down...")
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.error(f"Error running MCP server: {e}")
|
|
328
|
+
raise
|