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/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from jinja2 import Template
|
|
7
|
+
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from minitap.mcp.core.device import capture_screenshot, find_mobile_device
|
|
11
|
+
from minitap.mcp.core.llm import get_minitap_llm
|
|
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
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CompareScreenshotsOutput(BaseModel):
|
|
17
|
+
comparison_text: str
|
|
18
|
+
expected_screenshot_base64: str
|
|
19
|
+
current_screenshot_base64: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def compare_screenshots(
|
|
23
|
+
expected_screenshot_base64: str,
|
|
24
|
+
) -> CompareScreenshotsOutput:
|
|
25
|
+
"""
|
|
26
|
+
Compare screenshots and return the comparison text along with both screenshots.
|
|
27
|
+
|
|
28
|
+
Supports both local devices (Android/iOS) and cloud devices.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
CompareScreenshotsOutput
|
|
32
|
+
"""
|
|
33
|
+
system_message = Template(
|
|
34
|
+
Path(__file__).parent.joinpath("prompt.md").read_text(encoding="utf-8")
|
|
35
|
+
).render()
|
|
36
|
+
|
|
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)
|
|
45
|
+
|
|
46
|
+
messages: list[BaseMessage] = [
|
|
47
|
+
SystemMessage(content=system_message),
|
|
48
|
+
HumanMessage(content="Here is the Figma screenshot (what needs to be matched):"),
|
|
49
|
+
get_screenshot_message_for_llm(expected_screenshot_base64),
|
|
50
|
+
HumanMessage(content="Here is the screenshot of the mobile device:"),
|
|
51
|
+
get_screenshot_message_for_llm(current_screenshot),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
llm = get_minitap_llm(
|
|
55
|
+
trace_id=str(uuid4()),
|
|
56
|
+
remote_tracing=True,
|
|
57
|
+
model="google/gemini-2.5-pro",
|
|
58
|
+
temperature=1,
|
|
59
|
+
)
|
|
60
|
+
response = await llm.ainvoke(messages)
|
|
61
|
+
return CompareScreenshotsOutput(
|
|
62
|
+
comparison_text=str(response.content),
|
|
63
|
+
expected_screenshot_base64=expected_screenshot_base64,
|
|
64
|
+
current_screenshot_base64=current_screenshot,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def main():
|
|
69
|
+
expected_screenshot_base64 = "Base64 encoded screenshot to compare with."
|
|
70
|
+
result = await compare_screenshots(expected_screenshot_base64)
|
|
71
|
+
print(result.model_dump_json(indent=2))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
You will be given _two screenshots_.
|
|
2
|
+
|
|
3
|
+
1. "Expected screenshot" — this is the design from Figma.
|
|
4
|
+
2. "Implemented screenshot" — this is the actual phone screen that has been built.
|
|
5
|
+
|
|
6
|
+
Your task is to **compare the two screenshots** in detail, and generate a structured report that includes:
|
|
7
|
+
|
|
8
|
+
- A comprehensive list of **all visible differences** between the expected design and the implemented screen.
|
|
9
|
+
- For each difference, provide:
|
|
10
|
+
- A clear **description** of what changed (for example: "The 'Submit' button label changed from 'Submit' to 'Send'", "The icon moved 8px to the right", "The background colour of header changed from #FFFFFF to #F6F6F6", etc.).
|
|
11
|
+
- The **type of change** (e.g., text change, color change, position/movement, size change, added element, removed element, style change).
|
|
12
|
+
- The **location** of the change (for example: "bottom-centre of screen", "top header area", "to the right of search bar"). If possible, approximate coordinates or bounding box (e.g., "approx. 240×180 px at screen width 1080").
|
|
13
|
+
- The **impact on implementation** (i.e., reasoning about what this means: "The implemented version uses a different text label – so behaviour may differ", "The icon moved and may overlap another element", etc.).
|
|
14
|
+
- A **recommendation** if relevant (e.g., "Should revert to #FFFFFF to match design", "Check alignment of icon relative to search bar", etc.).
|
|
15
|
+
|
|
16
|
+
**Important**:
|
|
17
|
+
|
|
18
|
+
- Assume the screenshots are aligned (same resolution and scale); if not aligned mention that as a difference.
|
|
19
|
+
- Focus on _visible UI differences_ (layout, text, style, iconography) – you do _not_ need to inspect source code, only what is visually rendered.
|
|
20
|
+
- Do _not_ produce generic comments like "looks like a difference" – aim for _precise, actionable descriptions_.
|
|
21
|
+
- **IGNORE dynamic/personal content** that naturally differs between mockups and real implementations:
|
|
22
|
+
- User profile information (names, usernames, email addresses, profile pictures)
|
|
23
|
+
- Time-based information (current time, dates, timestamps, "2 hours ago", etc.)
|
|
24
|
+
- Dynamic data (notification counts, unread badges, live statistics)
|
|
25
|
+
- Sample/placeholder content that varies (e.g., "John Doe" vs "Jane Smith")
|
|
26
|
+
- System status information (battery level, signal strength, network indicators)
|
|
27
|
+
- Only flag these as differences if the _structure, layout, or styling_ of these elements differs, not the content itself.
|
|
28
|
+
- Output in a structured format, for example:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
1. Location: [top header – full width]
|
|
33
|
+
Change: Background colour changed from #FFFFFF → #F6F6F6
|
|
34
|
+
Type: Colour change
|
|
35
|
+
Impact: The header will appear darker than design; text contrast may be lower.
|
|
36
|
+
Recommendation: Update header background to #FFFFFF as in design.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- At the end produce a summary with ONLY:
|
|
41
|
+
- Total number of differences found
|
|
42
|
+
- Overall "match score" out of 100 (your estimation of how closely the implementation matches the design)
|
|
43
|
+
- Do NOT include any recap, overview, or macro-level summary of changes - all details are already captured in the differences list above.
|
|
44
|
+
|
|
45
|
+
### Input:
|
|
46
|
+
|
|
47
|
+
- Screenshot A: Expected (Figma)
|
|
48
|
+
- Screenshot B: Implemented (Phone)
|
|
49
|
+
Provide both screenshots and then the prompt.
|
|
50
|
+
|
|
51
|
+
### Output:
|
|
52
|
+
|
|
53
|
+
Structured list of differences + summary.
|
|
54
|
+
|
|
55
|
+
Please use the following to start the analysis.
|
|
56
|
+
**Input:**
|
|
57
|
+
First screen is the Figma screenshot (what is expected)
|
|
58
|
+
Second screen is what is implemented (taken from the phone, after the implementation)
|
|
59
|
+
|
|
60
|
+
You will have this data in the next messages sent by the user.
|
|
61
|
+
|
|
62
|
+
Go ahead and generate your report.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
6 differences found :
|
|
2
|
+
- Add button moved and restyled
|
|
3
|
+
The “+ Add” button is now at the top instead of the bottom, and its style is very different. This is a big layout change — confirm with design.
|
|
4
|
+
|
|
5
|
+
- Title typo
|
|
6
|
+
“Languges” should be “Languages”.
|
|
7
|
+
|
|
8
|
+
- Subtitle casing
|
|
9
|
+
The subtitle should be sentence case (“You are learning”), not title case.
|
|
10
|
+
|
|
11
|
+
- Extra robot illustration
|
|
12
|
+
A large yellow robot was added but isn’t in the design. Confirm if it should be there.
|
|
13
|
+
|
|
14
|
+
- Extra sub-label in list item
|
|
15
|
+
“Norwegian” has a second line (“from French”), which isn’t part of the design. Check if this is intended.
|
|
16
|
+
|
|
17
|
+
- Missing small “M” icon
|
|
18
|
+
The red “M” in the bottom-right is missing.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Based on the visual comparison of the provided screenshots, here is a detailed report of the differences.
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
1. **Location**: Top-right of the header area vs. bottom-center of the screen
|
|
6
|
+
**Change**: The `+ Add` button has been moved from a full-width element at the bottom of the screen to a smaller, pill-shaped button in the top-right header area. Its style has also changed from a button with only a text label and icon to one with a solid yellow background and white text/icon.
|
|
7
|
+
**Type**: Position/Movement, Style Change, Size Change
|
|
8
|
+
**Impact**: This is a major structural change to the page layout. The primary call-to-action is in a completely different location, which fundamentally alters the user's interaction pattern with the screen.
|
|
9
|
+
**Recommendation**: The development team should align with the design team on the intended placement and style of this critical action button. This change is too significant to be accidental and suggests a disconnect between design and implementation requirements.
|
|
10
|
+
|
|
11
|
+
2. **Location**: Page title area, below the `X` icon.
|
|
12
|
+
**Change**: The page title text is misspelled as "Languges" in the implementation, whereas the design specifies "Languages".
|
|
13
|
+
**Type**: Text Change
|
|
14
|
+
**Impact**: Introduces a spelling error on a primary screen title, which appears unprofessional.
|
|
15
|
+
**Recommendation**: Correct the typo in the title to "Languages".
|
|
16
|
+
|
|
17
|
+
3. **Location**: Subtitle area, directly below the page title.
|
|
18
|
+
**Change**: The text casing for the subtitle has been changed from sentence case ("You are learning") to title case ("You are Learning").
|
|
19
|
+
**Type**: Text Change / Style Change
|
|
20
|
+
**Impact**: A minor visual inconsistency that deviates from the typographic style defined in the design.
|
|
21
|
+
**Recommendation**: Update the subtitle text to use sentence case ("You are learning") to match the design specification.
|
|
22
|
+
|
|
23
|
+
4. **Location**: Bottom half of the screen.
|
|
24
|
+
**Change**: The implemented screen includes a large illustration of a yellow robot character that is not present in the expected design.
|
|
25
|
+
**Type**: Added Element
|
|
26
|
+
**Impact**: This new element significantly alters the visual hierarchy and feel of the screen, consuming a large amount of whitespace and adding a strong branding element not specified in the design.
|
|
27
|
+
**Recommendation**: Verify with the product/design team if this illustration is an intended addition. If not, it should be removed to match the figma design.
|
|
28
|
+
|
|
29
|
+
5. **Location**: Second item in the language list ("Norwegian").
|
|
30
|
+
**Change**: One of the language list items in the implementation includes a gray sub-label ("from French") below the language name. The design for list items shows only a single line of text for the language.
|
|
31
|
+
**Type**: Added Element (within a component)
|
|
32
|
+
**Impact**: The implemented list item component has a different structure than designed, supporting two lines of text instead of one. This affects component height and internal layout.
|
|
33
|
+
**Recommendation**: Clarify if this two-line state is a required feature. If so, the design should be updated to include this variant. If not, the sub-label should be removed.
|
|
34
|
+
|
|
35
|
+
6. **Location**: Bottom-right corner of the screen.
|
|
36
|
+
**Change**: The expected design includes a small, red "M" character/logo in the bottom-right corner. This element is missing from the implemented screen.
|
|
37
|
+
**Type**: Removed Element
|
|
38
|
+
**Impact**: A subtle branding or UI element has been omitted from the final implementation.
|
|
39
|
+
**Recommendation**: Add the "M" element back to the screen in the position specified by the design.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### Summary
|
|
44
|
+
|
|
45
|
+
- **Total differences found**: 6
|
|
46
|
+
- **Overall "match score"**: 50/100
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
You will be given _two screenshots_.
|
|
2
|
+
|
|
3
|
+
1. "Expected screenshot" — this is the design from Figma.
|
|
4
|
+
2. "Implemented screenshot" — this is the actual phone screen that has been built.
|
|
5
|
+
|
|
6
|
+
Your task is to **compare the two screenshots** in detail, and generate a structured report that includes:
|
|
7
|
+
|
|
8
|
+
- A comprehensive list of **all visible differences** between the expected design and the implemented screen.
|
|
9
|
+
- For each difference, provide:
|
|
10
|
+
- A clear **description** of what changed (for example: "The 'Submit' button label changed from 'Submit' to 'Send'", "The icon moved 8px to the right", "The background colour of header changed from #FFFFFF to #F6F6F6", etc.).
|
|
11
|
+
- The **type of change** (e.g., text change, color change, position/movement, size change, added element, removed element, style change).
|
|
12
|
+
- The **location** of the change (for example: "bottom-centre of screen", "top header area", "to the right of search bar"). If possible, approximate coordinates or bounding box (e.g., "approx. 240×180 px at screen width 1080").
|
|
13
|
+
- The **impact on implementation** (i.e., reasoning about what this means: "The implemented version uses a different text label – so behaviour may differ", "The icon moved and may overlap another element", etc.).
|
|
14
|
+
- A **recommendation** if relevant (e.g., "Should revert to #FFFFFF to match design", "Check alignment of icon relative to search bar", etc.).
|
|
15
|
+
|
|
16
|
+
**Important**:
|
|
17
|
+
|
|
18
|
+
- Assume the screenshots are aligned (same resolution and scale); if not aligned mention that as a difference.
|
|
19
|
+
- Focus on _visible UI differences_ (layout, text, style, iconography) – you do _not_ need to inspect source code, only what is visually rendered.
|
|
20
|
+
- Do _not_ produce generic comments like "looks like a difference" – aim for _precise, actionable descriptions_.
|
|
21
|
+
- **IGNORE dynamic/personal content** that naturally differs between mockups and real implementations:
|
|
22
|
+
- User profile information (names, usernames, email addresses, profile pictures)
|
|
23
|
+
- Time-based information (current time, dates, timestamps, "2 hours ago", etc.)
|
|
24
|
+
- Dynamic data (notification counts, unread badges, live statistics)
|
|
25
|
+
- Sample/placeholder content that varies (e.g., "John Doe" vs "Jane Smith")
|
|
26
|
+
- System status information (battery level, signal strength, network indicators)
|
|
27
|
+
- Only flag these as differences if the _structure, layout, or styling_ of these elements differs, not the content itself.
|
|
28
|
+
- Output in a structured format, for example:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
1. Location: [top header – full width]
|
|
33
|
+
Change: Background colour changed from #FFFFFF → #F6F6F6
|
|
34
|
+
Type: Colour change
|
|
35
|
+
Impact: The header will appear darker than design; text contrast may be lower.
|
|
36
|
+
Recommendation: Update header background to #FFFFFF as in design.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- At the end produce a summary with ONLY:
|
|
41
|
+
- Total number of differences found
|
|
42
|
+
- Overall "match score" out of 100 (your estimation of how closely the implementation matches the design)
|
|
43
|
+
- Do NOT include any recap, overview, or macro-level summary of changes - all details are already captured in the differences list above.
|
|
44
|
+
|
|
45
|
+
### Input:
|
|
46
|
+
|
|
47
|
+
- Screenshot A: Expected (Figma)
|
|
48
|
+
- Screenshot B: Implemented (Phone)
|
|
49
|
+
Provide both screenshots and then the prompt.
|
|
50
|
+
|
|
51
|
+
### Output:
|
|
52
|
+
|
|
53
|
+
Structured list of differences + summary.
|
|
54
|
+
|
|
55
|
+
Please use the following to start the analysis.
|
|
56
|
+
**Input:**
|
|
57
|
+
First screen is the Figma screenshot (what is expected)
|
|
58
|
+
Second screen is what is implemented (taken from the phone, after the implementation)
|
|
59
|
+
|
|
60
|
+
You will have this data in the next messages sent by the user.
|
|
61
|
+
|
|
62
|
+
Go ahead and generate your report.
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
# Use APP_PACKAGE_NAME env var if set, otherwise generate a random name
|
|
36
|
+
# Preserve the original file extension
|
|
37
|
+
extension = apk_file.suffix # e.g., ".apk"
|
|
38
|
+
if settings.APP_PACKAGE_NAME:
|
|
39
|
+
# Strip any existing extension from APP_PACKAGE_NAME to avoid double extensions
|
|
40
|
+
base_name = Path(settings.APP_PACKAGE_NAME).stem
|
|
41
|
+
filename = f"{base_name}{extension}"
|
|
42
|
+
else:
|
|
43
|
+
filename = f"app_{uuid.uuid4().hex[:6]}{extension}"
|
|
44
|
+
api_key = settings.MINITAP_API_KEY.get_secret_value()
|
|
45
|
+
api_base_url = settings.MINITAP_API_BASE_URL.rstrip("/")
|
|
46
|
+
|
|
47
|
+
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
48
|
+
# Step 1: Get signed upload URL from storage API
|
|
49
|
+
response = await client.get(
|
|
50
|
+
f"{api_base_url}/storage/signed-upload",
|
|
51
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
52
|
+
params={"filenames": filename},
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
upload_data = response.json()
|
|
56
|
+
|
|
57
|
+
# Extract the signed URL for our file
|
|
58
|
+
signed_urls = upload_data.get("signed_urls", {})
|
|
59
|
+
if filename not in signed_urls:
|
|
60
|
+
raise ValueError(f"No signed URL returned for {filename}")
|
|
61
|
+
|
|
62
|
+
signed_url = signed_urls[filename]
|
|
63
|
+
|
|
64
|
+
# Step 2: Upload APK to signed URL
|
|
65
|
+
with open(apk_file, "rb") as f:
|
|
66
|
+
upload_response = await client.put(
|
|
67
|
+
signed_url,
|
|
68
|
+
content=f.read(),
|
|
69
|
+
headers={"Content-Type": "application/vnd.android.package-archive"},
|
|
70
|
+
)
|
|
71
|
+
upload_response.raise_for_status()
|
|
72
|
+
|
|
73
|
+
# Step 3: Return filename for install-apk call
|
|
74
|
+
return filename
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def install_apk_on_cloud_mobile(filename: str) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Install an APK on a cloud mobile device via mobile-manager API.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
filename: Filename returned from upload_apk_to_cloud_mobile
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
httpx.HTTPError: If installation fails
|
|
86
|
+
ValueError: If required config settings are not configured
|
|
87
|
+
"""
|
|
88
|
+
if not settings.MINITAP_API_KEY:
|
|
89
|
+
raise ValueError("MINITAP_API_KEY is not configured")
|
|
90
|
+
if not settings.MINITAP_DAAS_API:
|
|
91
|
+
raise ValueError("MINITAP_DAAS_API is not configured")
|
|
92
|
+
if not settings.CLOUD_MOBILE_NAME:
|
|
93
|
+
raise ValueError("CLOUD_MOBILE_NAME is not configured")
|
|
94
|
+
|
|
95
|
+
api_key = settings.MINITAP_API_KEY.get_secret_value()
|
|
96
|
+
base_url = settings.MINITAP_DAAS_API
|
|
97
|
+
cloud_mobile_name = settings.CLOUD_MOBILE_NAME
|
|
98
|
+
|
|
99
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
100
|
+
cloud_mobile_response = await client.get(
|
|
101
|
+
f"{base_url}/virtual-mobiles/{cloud_mobile_name}",
|
|
102
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
103
|
+
)
|
|
104
|
+
cloud_mobile_response.raise_for_status()
|
|
105
|
+
response_data = cloud_mobile_response.json()
|
|
106
|
+
cloud_mobile_uuid = response_data.get("id")
|
|
107
|
+
if not cloud_mobile_uuid:
|
|
108
|
+
raise ValueError(f"Cloud mobile '{cloud_mobile_name}' response missing 'id' field")
|
|
109
|
+
response = await client.post(
|
|
110
|
+
f"{base_url}/virtual-mobiles/{cloud_mobile_uuid}/install-apk",
|
|
111
|
+
headers={
|
|
112
|
+
"Authorization": f"Bearer {api_key}",
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
},
|
|
115
|
+
json={"filename": filename},
|
|
116
|
+
)
|
|
117
|
+
response.raise_for_status()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Configuration for the MCP server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
from pydantic import Field, SecretStr, model_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
# Load environment variables from .env file
|
|
11
|
+
load_dotenv(verbose=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _derive_mcp_url_from_base(base_url: str) -> str:
|
|
15
|
+
"""Derive the MCP URL from the API base URL.
|
|
16
|
+
|
|
17
|
+
Extracts the scheme and host from the base URL and appends /api/mcp.
|
|
18
|
+
Example: https://dev.platform.minitap.ai/api/v1 -> https://dev.platform.minitap.ai/api/mcp/
|
|
19
|
+
"""
|
|
20
|
+
parsed = urlparse(base_url)
|
|
21
|
+
return f"{parsed.scheme}://{parsed.netloc}/api/mcp/"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _derive_daas_url_from_base(base_url: str) -> str:
|
|
25
|
+
"""Derive the DaaS API URL from the API base URL.
|
|
26
|
+
|
|
27
|
+
Extracts the scheme and host from the base URL and appends /api/daas.
|
|
28
|
+
Example: https://dev.platform.minitap.ai/api/v1 -> https://dev.platform.minitap.ai/api/daas/
|
|
29
|
+
"""
|
|
30
|
+
parsed = urlparse(base_url)
|
|
31
|
+
return f"{parsed.scheme}://{parsed.netloc}/api/daas"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _derive_platform_base_url(api_base_url: str) -> str:
|
|
35
|
+
"""Derive the platform base URL from the API base URL.
|
|
36
|
+
|
|
37
|
+
Extracts the scheme and host from the API URL (strips /api/v1 path).
|
|
38
|
+
Example: https://dev.platform.minitap.ai/api/v1 -> https://dev.platform.minitap.ai
|
|
39
|
+
"""
|
|
40
|
+
parsed = urlparse(api_base_url)
|
|
41
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MCPSettings(BaseSettings):
|
|
45
|
+
"""Configuration class for MCP server."""
|
|
46
|
+
|
|
47
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
48
|
+
|
|
49
|
+
# Minitap API configuration
|
|
50
|
+
MINITAP_API_KEY: SecretStr | None = Field(default=None)
|
|
51
|
+
MINITAP_API_BASE_URL: str = Field(default="https://platform.minitap.ai/api/v1")
|
|
52
|
+
|
|
53
|
+
# These URLs can be set explicitly, or will be derived from MINITAP_API_BASE_URL
|
|
54
|
+
MINITAP_DAAS_API: str | None = Field(default=None)
|
|
55
|
+
MINITAP_API_MCP_BASE_URL: str | None = Field(default=None)
|
|
56
|
+
|
|
57
|
+
OPEN_ROUTER_API_KEY: SecretStr | None = Field(default=None)
|
|
58
|
+
|
|
59
|
+
VISION_MODEL: str = Field(default="google/gemini-3-flash-preview")
|
|
60
|
+
|
|
61
|
+
# MCP server configuration (optional, for remote access)
|
|
62
|
+
MCP_SERVER_HOST: str = Field(default="0.0.0.0")
|
|
63
|
+
MCP_SERVER_PORT: int = Field(default=8000)
|
|
64
|
+
|
|
65
|
+
# Cloud Mobile configuration
|
|
66
|
+
# When set, the MCP server runs in cloud mode connecting to a Minitap cloud mobile
|
|
67
|
+
# instead of requiring a local device. Value can be a device name.
|
|
68
|
+
# Create cloud mobiles at https://platform.minitap.ai/cloud-mobiles
|
|
69
|
+
CLOUD_MOBILE_NAME: str | None = Field(default=None)
|
|
70
|
+
|
|
71
|
+
# Trajectory GIF download configuration
|
|
72
|
+
# When set, downloads the trajectory GIF after task execution to the specified folder.
|
|
73
|
+
# The folder is a directory where the GIF will be saved with the task run ID as filename.
|
|
74
|
+
TRAJECTORY_GIF_DOWNLOAD_FOLDER: str | None = Field(default=None)
|
|
75
|
+
|
|
76
|
+
# App package name override for uploads
|
|
77
|
+
# When set, uploaded APK/IPA files will use this name instead of a random UUID.
|
|
78
|
+
# The original file extension is preserved. Example: "my_app" -> "my_app.apk"
|
|
79
|
+
APP_PACKAGE_NAME: str | None = Field(default=None)
|
|
80
|
+
|
|
81
|
+
@model_validator(mode="after")
|
|
82
|
+
def derive_urls_from_base(self) -> "MCPSettings":
|
|
83
|
+
"""Derive MCP and DaaS URLs from base URL if not explicitly set.
|
|
84
|
+
|
|
85
|
+
This ensures that setting MINITAP_API_BASE_URL to a different environment
|
|
86
|
+
(e.g., dev) automatically updates all related URLs.
|
|
87
|
+
"""
|
|
88
|
+
if self.MINITAP_API_MCP_BASE_URL is None:
|
|
89
|
+
# Use object.__setattr__ to bypass Pydantic's frozen model protection
|
|
90
|
+
object.__setattr__(
|
|
91
|
+
self,
|
|
92
|
+
"MINITAP_API_MCP_BASE_URL",
|
|
93
|
+
_derive_mcp_url_from_base(self.MINITAP_API_BASE_URL),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if self.MINITAP_DAAS_API is None:
|
|
97
|
+
object.__setattr__(
|
|
98
|
+
self,
|
|
99
|
+
"MINITAP_DAAS_API",
|
|
100
|
+
_derive_daas_url_from_base(self.MINITAP_API_BASE_URL),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Set MINITAP_BASE_URL in environment for mobile-use SDK compatibility.
|
|
104
|
+
# The SDK uses MINITAP_BASE_URL (e.g., https://dev.platform.minitap.ai) while
|
|
105
|
+
# MCP uses MINITAP_API_BASE_URL (e.g., https://dev.platform.minitap.ai/api/v1).
|
|
106
|
+
os.environ["MINITAP_BASE_URL"] = _derive_platform_base_url(self.MINITAP_API_BASE_URL)
|
|
107
|
+
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
settings = MCPSettings() # type: ignore
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Decorators for MCP tools."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import traceback
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from minitap.mcp.core.device import DeviceNotFoundError, DeviceNotReadyError
|
|
10
|
+
from minitap.mcp.core.logging_config import get_logger
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle_tool_errors[T: Callable[..., Any]](func: T) -> T:
|
|
18
|
+
"""
|
|
19
|
+
Decorator that catches all exceptions in MCP tools and returns error messages.
|
|
20
|
+
|
|
21
|
+
This prevents unhandled exceptions from causing infinite loops in the MCP server.
|
|
22
|
+
Logs all errors with structured logging for better debugging.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@wraps(func)
|
|
26
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
27
|
+
try:
|
|
28
|
+
logger.info(
|
|
29
|
+
"tool_called",
|
|
30
|
+
tool_name=func.__name__,
|
|
31
|
+
args_count=len(args),
|
|
32
|
+
kwargs_keys=list(kwargs.keys()),
|
|
33
|
+
)
|
|
34
|
+
result = await func(*args, **kwargs)
|
|
35
|
+
logger.info("tool_completed", tool_name=func.__name__)
|
|
36
|
+
return result
|
|
37
|
+
except DeviceNotFoundError as e:
|
|
38
|
+
logger.error(
|
|
39
|
+
"device_not_found_error",
|
|
40
|
+
tool_name=func.__name__,
|
|
41
|
+
error=str(e),
|
|
42
|
+
error_type=type(e).__name__,
|
|
43
|
+
)
|
|
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)}"
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(
|
|
56
|
+
"tool_error",
|
|
57
|
+
tool_name=func.__name__,
|
|
58
|
+
error=str(e),
|
|
59
|
+
error_type=type(e).__name__,
|
|
60
|
+
traceback=traceback.format_exc(),
|
|
61
|
+
)
|
|
62
|
+
return f"Error in {func.__name__}: {type(e).__name__}: {str(e)}"
|
|
63
|
+
|
|
64
|
+
@wraps(func)
|
|
65
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
66
|
+
try:
|
|
67
|
+
logger.info(
|
|
68
|
+
"tool_called",
|
|
69
|
+
tool_name=func.__name__,
|
|
70
|
+
args_count=len(args),
|
|
71
|
+
kwargs_keys=list(kwargs.keys()),
|
|
72
|
+
)
|
|
73
|
+
result = func(*args, **kwargs)
|
|
74
|
+
logger.info("tool_completed", tool_name=func.__name__)
|
|
75
|
+
return result
|
|
76
|
+
except DeviceNotFoundError as e:
|
|
77
|
+
logger.error(
|
|
78
|
+
"device_not_found_error",
|
|
79
|
+
tool_name=func.__name__,
|
|
80
|
+
error=str(e),
|
|
81
|
+
error_type=type(e).__name__,
|
|
82
|
+
)
|
|
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)}"
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(
|
|
95
|
+
"tool_error",
|
|
96
|
+
tool_name=func.__name__,
|
|
97
|
+
error=str(e),
|
|
98
|
+
error_type=type(e).__name__,
|
|
99
|
+
traceback=traceback.format_exc(),
|
|
100
|
+
)
|
|
101
|
+
return f"Error in {func.__name__}: {type(e).__name__}: {str(e)}"
|
|
102
|
+
|
|
103
|
+
# Check if the function is async
|
|
104
|
+
if inspect.iscoroutinefunction(func):
|
|
105
|
+
return async_wrapper # type: ignore
|
|
106
|
+
else:
|
|
107
|
+
return sync_wrapper # type: ignore
|