minitap-mcp 0.6.0__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/core/config.py +2 -4
- minitap/mcp/core/decorators.py +19 -1
- minitap/mcp/core/device.py +11 -4
- minitap/mcp/core/storage.py +274 -0
- minitap/mcp/main.py +57 -5
- minitap/mcp/server/cloud_mobile.py +95 -0
- minitap/mcp/server/remote_proxy.py +96 -0
- minitap/mcp/tools/execute_mobile_command.py +4 -0
- minitap/mcp/tools/read_swift_logs.py +297 -0
- minitap/mcp/tools/take_screenshot.py +53 -0
- minitap/mcp/tools/upload_screenshot.py +80 -0
- {minitap_mcp-0.6.0.dist-info → minitap_mcp-0.7.0.dist-info}/METADATA +4 -3
- {minitap_mcp-0.6.0.dist-info → minitap_mcp-0.7.0.dist-info}/RECORD +15 -13
- {minitap_mcp-0.6.0.dist-info → minitap_mcp-0.7.0.dist-info}/WHEEL +2 -2
- minitap/mcp/tools/analyze_screen.py +0 -69
- minitap/mcp/tools/compare_screenshot_with_figma.py +0 -132
- minitap/mcp/tools/save_figma_assets.py +0 -276
- {minitap_mcp-0.6.0.dist-info → minitap_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
minitap/mcp/core/config.py
CHANGED
|
@@ -17,12 +17,10 @@ class MCPSettings(BaseSettings):
|
|
|
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
19
|
MINITAP_DAAS_API: str = Field(default="https://platform.minitap.ai/api/daas")
|
|
20
|
+
MINITAP_API_MCP_BASE_URL: str | None = Field(default="https://platform.minitap.ai/mcp")
|
|
20
21
|
OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
|
|
21
22
|
|
|
22
|
-
VISION_MODEL: str = Field(default="
|
|
23
|
-
|
|
24
|
-
# Figma MCP server configuration
|
|
25
|
-
FIGMA_MCP_SERVER_URL: str = Field(default="http://127.0.0.1:3845/mcp")
|
|
23
|
+
VISION_MODEL: str = Field(default="google/gemini-3-flash-preview")
|
|
26
24
|
|
|
27
25
|
# MCP server configuration (optional, for remote access)
|
|
28
26
|
MCP_SERVER_HOST: str = Field(default="0.0.0.0")
|
minitap/mcp/core/decorators.py
CHANGED
|
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
|
|
6
6
|
from functools import wraps
|
|
7
7
|
from typing import Any, TypeVar
|
|
8
8
|
|
|
9
|
-
from minitap.mcp.core.device import DeviceNotFoundError
|
|
9
|
+
from minitap.mcp.core.device import DeviceNotFoundError, DeviceNotReadyError
|
|
10
10
|
from minitap.mcp.core.logging_config import get_logger
|
|
11
11
|
|
|
12
12
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
@@ -42,6 +42,15 @@ def handle_tool_errors[T: Callable[..., Any]](func: T) -> T:
|
|
|
42
42
|
error_type=type(e).__name__,
|
|
43
43
|
)
|
|
44
44
|
return f"Error: {str(e)}"
|
|
45
|
+
except DeviceNotReadyError as e:
|
|
46
|
+
logger.error(
|
|
47
|
+
"device_not_ready_error",
|
|
48
|
+
tool_name=func.__name__,
|
|
49
|
+
error=str(e),
|
|
50
|
+
error_type=type(e).__name__,
|
|
51
|
+
device_state=e.state,
|
|
52
|
+
)
|
|
53
|
+
return f"Error: {str(e)}"
|
|
45
54
|
except Exception as e:
|
|
46
55
|
logger.error(
|
|
47
56
|
"tool_error",
|
|
@@ -72,6 +81,15 @@ def handle_tool_errors[T: Callable[..., Any]](func: T) -> T:
|
|
|
72
81
|
error_type=type(e).__name__,
|
|
73
82
|
)
|
|
74
83
|
return f"Error: {str(e)}"
|
|
84
|
+
except DeviceNotReadyError as e:
|
|
85
|
+
logger.error(
|
|
86
|
+
"device_not_ready_error",
|
|
87
|
+
tool_name=func.__name__,
|
|
88
|
+
error=str(e),
|
|
89
|
+
error_type=type(e).__name__,
|
|
90
|
+
device_state=e.state,
|
|
91
|
+
)
|
|
92
|
+
return f"Error: {str(e)}"
|
|
75
93
|
except Exception as e:
|
|
76
94
|
logger.error(
|
|
77
95
|
"tool_error",
|
minitap/mcp/core/device.py
CHANGED
|
@@ -11,7 +11,6 @@ from typing import Literal
|
|
|
11
11
|
from adbutils import AdbClient, AdbDevice
|
|
12
12
|
from pydantic import BaseModel, ConfigDict
|
|
13
13
|
|
|
14
|
-
|
|
15
14
|
DevicePlatform = Literal["android", "ios"]
|
|
16
15
|
|
|
17
16
|
|
|
@@ -40,6 +39,14 @@ class DeviceNotFoundError(Exception):
|
|
|
40
39
|
pass
|
|
41
40
|
|
|
42
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
|
+
|
|
43
50
|
def get_adb_client() -> AdbClient:
|
|
44
51
|
"""Get an ADB client instance."""
|
|
45
52
|
custom_adb_socket = os.getenv("ADB_SERVER_SOCKET")
|
|
@@ -80,9 +87,9 @@ def list_available_devices() -> list[DeviceInfo]:
|
|
|
80
87
|
# ADB not available or error listing devices
|
|
81
88
|
pass
|
|
82
89
|
|
|
83
|
-
# List iOS devices
|
|
90
|
+
# List iOS devices (only booted simulators to match SDK behavior)
|
|
84
91
|
try:
|
|
85
|
-
cmd = ["xcrun", "simctl", "list", "devices", "-j"]
|
|
92
|
+
cmd = ["xcrun", "simctl", "list", "devices", "booted", "-j"]
|
|
86
93
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
87
94
|
data = json.loads(result.stdout)
|
|
88
95
|
|
|
@@ -95,7 +102,7 @@ def list_available_devices() -> list[DeviceInfo]:
|
|
|
95
102
|
name = device.get("name")
|
|
96
103
|
state = device.get("state")
|
|
97
104
|
|
|
98
|
-
if udid:
|
|
105
|
+
if udid and state == "Booted":
|
|
99
106
|
devices.append(
|
|
100
107
|
DeviceInfo(
|
|
101
108
|
device_id=udid,
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Storage utilities for uploading local files to remote storage.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to upload local files (like screenshots)
|
|
4
|
+
to the MaaS API storage backend and get presigned URLs that can be passed
|
|
5
|
+
to remote MCP tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from minitap.mcp.core.config import settings
|
|
15
|
+
from minitap.mcp.core.logging_config import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StorageUploadError(Exception):
|
|
21
|
+
"""Error raised when file upload fails."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_api_key() -> str:
|
|
27
|
+
"""Get the API key from settings.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The API key string
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
StorageUploadError: If API key is not configured
|
|
34
|
+
"""
|
|
35
|
+
api_key = settings.MINITAP_API_KEY.get_secret_value() if settings.MINITAP_API_KEY else None
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise StorageUploadError("MINITAP_API_KEY is required for file uploads")
|
|
38
|
+
return api_key
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _generate_filename(content_type: str) -> str:
|
|
42
|
+
"""Generate a unique filename based on content type.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
content_type: MIME type of the file
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
UUID-based filename with appropriate extension
|
|
49
|
+
"""
|
|
50
|
+
ext = _get_extension_from_mime_type(content_type)
|
|
51
|
+
return f"{uuid.uuid4()}.{ext}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _get_signed_upload_url(
|
|
55
|
+
client: httpx.AsyncClient,
|
|
56
|
+
filename: str,
|
|
57
|
+
api_key: str,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Get a signed upload URL from the MaaS API.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client: HTTP client to use for the request
|
|
63
|
+
filename: Name of the file to upload
|
|
64
|
+
api_key: API key for authentication
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Signed upload URL
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
StorageUploadError: If request fails or no URL is returned
|
|
71
|
+
"""
|
|
72
|
+
base_url = settings.MINITAP_API_BASE_URL
|
|
73
|
+
endpoint = f"{base_url}/storage/signed-upload"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
logger.debug("Requesting signed upload URL", filename=filename)
|
|
77
|
+
response = await client.get(
|
|
78
|
+
endpoint,
|
|
79
|
+
params={"filenames": filename},
|
|
80
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if response.status_code != 200:
|
|
84
|
+
logger.error(
|
|
85
|
+
"Failed to get signed upload URL",
|
|
86
|
+
status_code=response.status_code,
|
|
87
|
+
response=response.text,
|
|
88
|
+
)
|
|
89
|
+
raise StorageUploadError(
|
|
90
|
+
f"Failed to get signed upload URL: HTTP {response.status_code}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
signed_urls = response.json().get("signed_urls", {})
|
|
94
|
+
if filename not in signed_urls:
|
|
95
|
+
raise StorageUploadError(f"No signed URL returned for {filename}")
|
|
96
|
+
|
|
97
|
+
logger.debug("Got signed upload URL", filename=filename)
|
|
98
|
+
return signed_urls[filename]
|
|
99
|
+
|
|
100
|
+
except httpx.TimeoutException as e:
|
|
101
|
+
logger.error("Signed URL request timed out", error=str(e))
|
|
102
|
+
raise StorageUploadError("Signed URL request timed out") from e
|
|
103
|
+
except httpx.RequestError as e:
|
|
104
|
+
logger.error("Signed URL request failed", error=str(e))
|
|
105
|
+
raise StorageUploadError(f"Signed URL request failed: {str(e)}") from e
|
|
106
|
+
except StorageUploadError:
|
|
107
|
+
raise
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error("Unexpected error getting signed URL", error=str(e))
|
|
110
|
+
raise StorageUploadError(f"Unexpected error: {str(e)}") from e
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _upload_to_signed_url(
|
|
114
|
+
client: httpx.AsyncClient,
|
|
115
|
+
url: str,
|
|
116
|
+
content: bytes,
|
|
117
|
+
content_type: str,
|
|
118
|
+
filename: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Upload content to a signed URL.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
client: HTTP client to use for the request
|
|
124
|
+
url: Signed upload URL
|
|
125
|
+
content: File content as bytes
|
|
126
|
+
content_type: MIME type of the content
|
|
127
|
+
filename: Filename (for logging)
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
StorageUploadError: If upload fails
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
logger.debug("Uploading file to storage", filename=filename, size=len(content))
|
|
134
|
+
response = await client.put(
|
|
135
|
+
url,
|
|
136
|
+
content=content,
|
|
137
|
+
headers={"Content-Type": content_type},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if response.status_code not in (200, 201):
|
|
141
|
+
logger.error(
|
|
142
|
+
"Failed to upload file",
|
|
143
|
+
status_code=response.status_code,
|
|
144
|
+
response=response.text,
|
|
145
|
+
)
|
|
146
|
+
raise StorageUploadError(f"Failed to upload file: HTTP {response.status_code}")
|
|
147
|
+
|
|
148
|
+
logger.info("File uploaded successfully", filename=filename)
|
|
149
|
+
|
|
150
|
+
except httpx.TimeoutException as e:
|
|
151
|
+
logger.error("Upload request timed out", error=str(e))
|
|
152
|
+
raise StorageUploadError("Upload request timed out") from e
|
|
153
|
+
except httpx.RequestError as e:
|
|
154
|
+
logger.error("Upload request failed", error=str(e))
|
|
155
|
+
raise StorageUploadError(f"Upload request failed: {str(e)}") from e
|
|
156
|
+
except StorageUploadError:
|
|
157
|
+
raise
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.error("Unexpected error during upload", error=str(e))
|
|
160
|
+
raise StorageUploadError(f"Unexpected error: {str(e)}") from e
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def upload_file_to_storage(
|
|
164
|
+
file_content: bytes,
|
|
165
|
+
filename: str | None = None,
|
|
166
|
+
content_type: str = "image/png",
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Upload file content to remote storage and return the filename.
|
|
169
|
+
|
|
170
|
+
This function:
|
|
171
|
+
1. Gets a signed upload URL from the MaaS API
|
|
172
|
+
2. Uploads the file content to that URL
|
|
173
|
+
3. Returns the filename for use with remote MCP tools
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
file_content: The file content as bytes
|
|
177
|
+
filename: Optional filename (will generate UUID-based name if not provided)
|
|
178
|
+
content_type: MIME type of the file (default: image/png)
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Filename of the uploaded file (to be used with remote MCP tools)
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
StorageUploadError: If upload fails at any step
|
|
185
|
+
"""
|
|
186
|
+
api_key = _get_api_key()
|
|
187
|
+
filename = filename or _generate_filename(content_type)
|
|
188
|
+
|
|
189
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
190
|
+
signed_url = await _get_signed_upload_url(client, filename, api_key)
|
|
191
|
+
await _upload_to_signed_url(client, signed_url, file_content, content_type, filename)
|
|
192
|
+
|
|
193
|
+
return filename
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def upload_screenshot_to_storage(screenshot_base64: str) -> str:
|
|
197
|
+
"""Upload a base64-encoded screenshot to storage.
|
|
198
|
+
|
|
199
|
+
Convenience function for uploading screenshots captured from devices.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
screenshot_base64: Base64-encoded screenshot data
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Filename of the uploaded screenshot
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
StorageUploadError: If upload fails
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
screenshot_bytes = base64.b64decode(screenshot_base64)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
raise StorageUploadError(f"Invalid base64 data: {str(e)}") from e
|
|
215
|
+
|
|
216
|
+
return await upload_file_to_storage(
|
|
217
|
+
file_content=screenshot_bytes,
|
|
218
|
+
content_type="image/png",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def upload_local_file_to_storage(file_path: str | Path) -> str:
|
|
223
|
+
"""Upload a local file to storage.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
file_path: Path to the local file
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Public download URL for the uploaded file
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
StorageUploadError: If file doesn't exist or upload fails
|
|
233
|
+
"""
|
|
234
|
+
path = Path(file_path)
|
|
235
|
+
|
|
236
|
+
if not path.exists():
|
|
237
|
+
raise StorageUploadError(f"File not found: {file_path}")
|
|
238
|
+
|
|
239
|
+
mime_type = _guess_mime_type(path.suffix)
|
|
240
|
+
file_content = path.read_bytes()
|
|
241
|
+
|
|
242
|
+
return await upload_file_to_storage(
|
|
243
|
+
file_content=file_content,
|
|
244
|
+
filename=f"{uuid.uuid4()}{path.suffix}",
|
|
245
|
+
content_type=mime_type,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _get_extension_from_mime_type(mime_type: str) -> str:
|
|
250
|
+
"""Get file extension from MIME type."""
|
|
251
|
+
mime_to_ext = {
|
|
252
|
+
"image/png": "png",
|
|
253
|
+
"image/jpeg": "jpg",
|
|
254
|
+
"image/gif": "gif",
|
|
255
|
+
"image/webp": "webp",
|
|
256
|
+
"application/json": "json",
|
|
257
|
+
"text/plain": "txt",
|
|
258
|
+
}
|
|
259
|
+
return mime_to_ext.get(mime_type, "bin")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _guess_mime_type(extension: str) -> str:
|
|
263
|
+
"""Guess MIME type from file extension."""
|
|
264
|
+
ext = extension.lower().lstrip(".")
|
|
265
|
+
ext_to_mime = {
|
|
266
|
+
"png": "image/png",
|
|
267
|
+
"jpg": "image/jpeg",
|
|
268
|
+
"jpeg": "image/jpeg",
|
|
269
|
+
"gif": "image/gif",
|
|
270
|
+
"webp": "image/webp",
|
|
271
|
+
"json": "application/json",
|
|
272
|
+
"txt": "text/plain",
|
|
273
|
+
}
|
|
274
|
+
return ext_to_mime.get(ext, "application/octet-stream")
|
minitap/mcp/main.py
CHANGED
|
@@ -25,7 +25,6 @@ if sys.platform == "win32":
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
from fastmcp import FastMCP # noqa: E402
|
|
28
|
-
from minitap.mobile_use.config import settings as sdk_settings
|
|
29
28
|
|
|
30
29
|
from minitap.mcp.core.config import settings # noqa: E402
|
|
31
30
|
from minitap.mcp.core.device import (
|
|
@@ -39,6 +38,7 @@ from minitap.mcp.core.logging_config import (
|
|
|
39
38
|
from minitap.mcp.server.cloud_mobile import CloudMobileService
|
|
40
39
|
from minitap.mcp.server.middleware import LocalDeviceHealthMiddleware
|
|
41
40
|
from minitap.mcp.server.poller import device_health_poller
|
|
41
|
+
from minitap.mobile_use.config import settings as sdk_settings
|
|
42
42
|
|
|
43
43
|
configure_logging(log_level=os.getenv("LOG_LEVEL", "INFO"))
|
|
44
44
|
|
|
@@ -126,6 +126,7 @@ class MCPLifespanContext:
|
|
|
126
126
|
cloud_mobile_service: CloudMobileService | None = None
|
|
127
127
|
local_poller_stop_event: threading.Event | None = None
|
|
128
128
|
local_poller_thread: threading.Thread | None = None
|
|
129
|
+
remote_mcp_proxy: FastMCP | None = None
|
|
129
130
|
|
|
130
131
|
|
|
131
132
|
@asynccontextmanager
|
|
@@ -210,6 +211,46 @@ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
|
|
|
210
211
|
context.local_poller_thread = poller_thread
|
|
211
212
|
logger.info("Device health poller started")
|
|
212
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
|
+
|
|
213
254
|
try:
|
|
214
255
|
yield context
|
|
215
256
|
finally:
|
|
@@ -236,6 +277,16 @@ async def mcp_lifespan(server: FastMCP) -> AsyncIterator[MCPLifespanContext]:
|
|
|
236
277
|
else:
|
|
237
278
|
logger.info("Device health poller stopped successfully")
|
|
238
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
|
+
|
|
239
290
|
logger.info("MCP server shutdown complete")
|
|
240
291
|
|
|
241
292
|
|
|
@@ -250,10 +301,11 @@ mcp = FastMCP(
|
|
|
250
301
|
lifespan=mcp_lifespan,
|
|
251
302
|
)
|
|
252
303
|
|
|
253
|
-
from minitap.mcp.tools import
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
)
|
|
257
309
|
|
|
258
310
|
|
|
259
311
|
@mcp.resource("data://devices")
|
|
@@ -19,6 +19,7 @@ from typing import Any
|
|
|
19
19
|
import aiohttp
|
|
20
20
|
|
|
21
21
|
from minitap.mcp.core.config import settings
|
|
22
|
+
from minitap.mcp.core.device import DeviceNotReadyError
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
@@ -395,3 +396,97 @@ async def get_cloud_screenshot(mobile_id: str | None = None) -> bytes:
|
|
|
395
396
|
)
|
|
396
397
|
|
|
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,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
|
|
@@ -13,6 +13,7 @@ from minitap.mcp.core.config import settings
|
|
|
13
13
|
from minitap.mcp.core.decorators import handle_tool_errors
|
|
14
14
|
from minitap.mcp.core.sdk_agent import get_mobile_use_agent
|
|
15
15
|
from minitap.mcp.main import mcp
|
|
16
|
+
from minitap.mcp.server.cloud_mobile import check_cloud_mobile_status
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _serialize_result(result: Any) -> Any:
|
|
@@ -79,6 +80,9 @@ async def execute_mobile_command(
|
|
|
79
80
|
) -> str | dict[str, Any]:
|
|
80
81
|
"""Run a manual task on a mobile device via the Minitap platform."""
|
|
81
82
|
try:
|
|
83
|
+
if settings.CLOUD_MOBILE_NAME:
|
|
84
|
+
await check_cloud_mobile_status(settings.CLOUD_MOBILE_NAME)
|
|
85
|
+
|
|
82
86
|
if apk_path:
|
|
83
87
|
if not settings.CLOUD_MOBILE_NAME:
|
|
84
88
|
raise ToolError(
|