minitap-mcp 0.5.3__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.
minitap/mcp/main.py CHANGED
@@ -4,6 +4,9 @@ import argparse
4
4
  import os
5
5
  import sys
6
6
  import threading
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
7
10
 
8
11
  # Fix Windows console encoding for Unicode characters (emojis in logs)
9
12
  if sys.platform == "win32":
@@ -22,17 +25,20 @@ if sys.platform == "win32":
22
25
 
23
26
 
24
27
  from fastmcp import FastMCP # noqa: E402
25
- from minitap.mobile_use.config import settings as sdk_settings
26
28
 
27
29
  from minitap.mcp.core.config import settings # noqa: E402
28
- from minitap.mcp.core.device import DeviceInfo # noqa: E402
29
- from minitap.mcp.core.device import list_available_devices
30
+ from minitap.mcp.core.device import (
31
+ DeviceInfo, # noqa: E402
32
+ list_available_devices,
33
+ )
30
34
  from minitap.mcp.core.logging_config import (
31
35
  configure_logging, # noqa: E402
32
36
  get_logger,
33
37
  )
34
- from minitap.mcp.server.middleware import MaestroCheckerMiddleware
38
+ from minitap.mcp.server.cloud_mobile import CloudMobileService
39
+ from minitap.mcp.server.middleware import LocalDeviceHealthMiddleware
35
40
  from minitap.mcp.server.poller import device_health_poller
41
+ from minitap.mobile_use.config import settings as sdk_settings
36
42
 
37
43
  configure_logging(log_level=os.getenv("LOG_LEVEL", "INFO"))
38
44
 
@@ -41,7 +47,20 @@ def main() -> None:
41
47
  """Main entry point for the MCP server."""
42
48
 
43
49
  parser = argparse.ArgumentParser(description="Mobile Use MCP Server")
44
- parser.add_argument("--api-key", type=str, required=False, default=None)
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
+ )
45
64
  parser.add_argument("--llm-profile", type=str, required=False, default=None)
46
65
  parser.add_argument(
47
66
  "--server",
@@ -63,6 +82,11 @@ def main() -> None:
63
82
  settings.__init__()
64
83
  sdk_settings.__init__()
65
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
+
66
90
  if args.llm_profile:
67
91
  os.environ["MINITAP_LLM_PROFILE_NAME"] = args.llm_profile
68
92
  settings.__init__()
@@ -79,18 +103,194 @@ def main() -> None:
79
103
  # Run MCP server with optional host/port for remote access
80
104
  if args.server:
81
105
  logger.info(f"Starting MCP server on {settings.MCP_SERVER_HOST}:{settings.MCP_SERVER_PORT}")
82
- mcp_lifespan(
106
+ run_mcp_server(
83
107
  transport="http",
84
108
  host=settings.MCP_SERVER_HOST,
85
109
  port=settings.MCP_SERVER_PORT,
86
110
  )
87
111
  else:
88
112
  logger.info("Starting MCP server in local mode")
89
- mcp_lifespan()
113
+ run_mcp_server()
90
114
 
91
115
 
92
116
  logger = get_logger(__name__)
93
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
94
294
  mcp = FastMCP(
95
295
  name="mobile-use-mcp",
96
296
  instructions="""
@@ -98,11 +298,14 @@ mcp = FastMCP(
98
298
  mobile devices (iOS or Android).
99
299
  Call get_available_devices() to list them.
100
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
101
308
  )
102
- from minitap.mcp.tools import analyze_screen # noqa: E402, F401
103
- from minitap.mcp.tools import compare_screenshot_with_figma # noqa: E402, F401
104
- from minitap.mcp.tools import execute_mobile_command # noqa: E402, F401
105
- from minitap.mcp.tools import save_figma_assets # noqa: E402, F401
106
309
 
107
310
 
108
311
  @mcp.resource("data://devices")
@@ -111,40 +314,15 @@ def get_available_devices() -> list[DeviceInfo]:
111
314
  return list_available_devices()
112
315
 
113
316
 
114
- def mcp_lifespan(**mcp_run_kwargs):
115
- from minitap.mcp.core.sdk_agent import get_mobile_use_agent # noqa: E402
116
-
117
- agent = get_mobile_use_agent()
118
- mcp.add_middleware(MaestroCheckerMiddleware(agent))
119
-
120
- # Start device health poller in background
121
- logger.info("Device health poller started")
122
- stop_event = threading.Event()
123
- poller_thread = threading.Thread(
124
- target=device_health_poller,
125
- args=(
126
- stop_event,
127
- agent,
128
- ),
129
- daemon=True,
130
- )
131
- poller_thread.start()
317
+ def run_mcp_server(**mcp_run_kwargs):
318
+ """Run the MCP server with proper exception handling.
132
319
 
320
+ This wraps mcp.run() with exception handling for clean shutdown.
321
+ """
133
322
  try:
134
323
  mcp.run(**mcp_run_kwargs)
135
324
  except KeyboardInterrupt:
136
325
  logger.info("Keyboard interrupt received, shutting down...")
137
326
  except Exception as e:
138
327
  logger.error(f"Error running MCP server: {e}")
139
- finally:
140
- # Stop device health poller
141
- logger.info("Stopping device health poller...")
142
- stop_event.set()
143
-
144
- # Give the poller thread a reasonable time to stop gracefully
145
- poller_thread.join(timeout=10.0)
146
-
147
- if poller_thread.is_alive():
148
- logger.warning("Device health poller thread did not stop gracefully")
149
- else:
150
- logger.info("Device health poller stopped successfully")
328
+ raise