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/core/agents/compare_screenshots/agent.py +12 -2
- minitap/mcp/core/cloud_apk.py +109 -0
- minitap/mcp/core/config.py +9 -4
- minitap/mcp/core/decorators.py +19 -1
- minitap/mcp/core/device.py +11 -4
- minitap/mcp/core/sdk_agent.py +8 -0
- minitap/mcp/core/storage.py +274 -0
- minitap/mcp/main.py +219 -41
- minitap/mcp/server/cloud_mobile.py +492 -0
- minitap/mcp/server/middleware.py +11 -13
- minitap/mcp/server/poller.py +6 -6
- minitap/mcp/server/remote_proxy.py +96 -0
- minitap/mcp/tools/execute_mobile_command.py +47 -2
- 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.5.3.dist-info → minitap_mcp-0.7.0.dist-info}/METADATA +59 -5
- minitap_mcp-0.7.0.dist-info/RECORD +34 -0
- {minitap_mcp-0.5.3.dist-info → minitap_mcp-0.7.0.dist-info}/WHEEL +2 -2
- minitap/mcp/tools/analyze_screen.py +0 -58
- minitap/mcp/tools/compare_screenshot_with_figma.py +0 -132
- minitap/mcp/tools/save_figma_assets.py +0 -276
- minitap_mcp-0.5.3.dist-info/RECORD +0 -30
- {minitap_mcp-0.5.3.dist-info → minitap_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
-
|
|
34
|
-
|
|
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()
|
minitap/mcp/core/config.py
CHANGED
|
@@ -16,16 +16,21 @@ 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")
|
|
20
|
+
MINITAP_API_MCP_BASE_URL: str | None = Field(default="https://platform.minitap.ai/mcp")
|
|
19
21
|
OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
|
|
20
22
|
|
|
21
|
-
VISION_MODEL: str = Field(default="
|
|
22
|
-
|
|
23
|
-
# Figma MCP server configuration
|
|
24
|
-
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")
|
|
25
24
|
|
|
26
25
|
# MCP server configuration (optional, for remote access)
|
|
27
26
|
MCP_SERVER_HOST: str = Field(default="0.0.0.0")
|
|
28
27
|
MCP_SERVER_PORT: int = Field(default=8000)
|
|
29
28
|
|
|
29
|
+
# Cloud Mobile configuration
|
|
30
|
+
# When set, the MCP server runs in cloud mode connecting to a Minitap cloud mobile
|
|
31
|
+
# instead of requiring a local device. Value can be a device name.
|
|
32
|
+
# Create cloud mobiles at https://platform.minitap.ai/cloud-mobiles
|
|
33
|
+
CLOUD_MOBILE_NAME: str | None = Field(default=None)
|
|
34
|
+
|
|
30
35
|
|
|
31
36
|
settings = MCPSettings() # type: ignore
|
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,
|
minitap/mcp/core/sdk_agent.py
CHANGED
|
@@ -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,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")
|