quash-mcp 0.2.1__py3-none-any.whl → 0.2.3__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.
Potentially problematic release.
This version of quash-mcp might be problematic. Click here for more details.
- quash_mcp/backend_client.py +92 -2
- quash_mcp/device/state_capture.py +115 -0
- quash_mcp/tools/execute.py +11 -157
- quash_mcp/tools/execute_v2_backup.py +177 -0
- quash_mcp/tools/execute_v3.py +306 -0
- {quash_mcp-0.2.1.dist-info → quash_mcp-0.2.3.dist-info}/METADATA +1 -1
- {quash_mcp-0.2.1.dist-info → quash_mcp-0.2.3.dist-info}/RECORD +9 -6
- {quash_mcp-0.2.1.dist-info → quash_mcp-0.2.3.dist-info}/WHEEL +0 -0
- {quash_mcp-0.2.1.dist-info → quash_mcp-0.2.3.dist-info}/entry_points.txt +0 -0
quash_mcp/backend_client.py
CHANGED
|
@@ -15,8 +15,8 @@ class BackendClient:
|
|
|
15
15
|
"""Client for communicating with Quash backend API."""
|
|
16
16
|
|
|
17
17
|
def __init__(self):
|
|
18
|
-
# Get backend URL from environment variable, default to
|
|
19
|
-
self.base_url = os.getenv("MAHORAGA_BACKEND_URL", "http://
|
|
18
|
+
# Get backend URL from environment variable, default to production backend
|
|
19
|
+
self.base_url = os.getenv("MAHORAGA_BACKEND_URL", "http://13.220.180.140:8000")
|
|
20
20
|
self.timeout = 300.0 # 5 minutes for long-running LLM calls
|
|
21
21
|
logger.info(f"🔧 Backend client initialized: URL={self.base_url}")
|
|
22
22
|
|
|
@@ -190,6 +190,96 @@ class BackendClient:
|
|
|
190
190
|
logger.error(f"Failed to log execution: {e}")
|
|
191
191
|
return {"logged": False, "error": str(e)}
|
|
192
192
|
|
|
193
|
+
async def execute_step(
|
|
194
|
+
self,
|
|
195
|
+
api_key: str,
|
|
196
|
+
session_id: str,
|
|
197
|
+
step_number: int,
|
|
198
|
+
task: str,
|
|
199
|
+
ui_state: Dict[str, Any],
|
|
200
|
+
chat_history: list,
|
|
201
|
+
config: Dict[str, Any],
|
|
202
|
+
screenshot_bytes: Optional[bytes] = None
|
|
203
|
+
) -> Dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
Execute single agent step (V3 - Step-by-step execution).
|
|
206
|
+
|
|
207
|
+
Sends device state to backend, receives next action to execute.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
api_key: Quash API key
|
|
211
|
+
session_id: Unique session identifier
|
|
212
|
+
step_number: Current step number
|
|
213
|
+
task: Original task description
|
|
214
|
+
ui_state: Device UI state (a11y_tree, phone_state)
|
|
215
|
+
chat_history: Previous conversation messages
|
|
216
|
+
config: Execution configuration
|
|
217
|
+
screenshot_bytes: Optional screenshot (only if vision=True)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Dict with action to execute:
|
|
221
|
+
{
|
|
222
|
+
"action": {"type": str, "code": str, "reasoning": str},
|
|
223
|
+
"completed": bool,
|
|
224
|
+
"success": bool (if completed),
|
|
225
|
+
"final_message": str (if completed),
|
|
226
|
+
"assistant_response": str,
|
|
227
|
+
"tokens_used": {"prompt": int, "completion": int, "total": int},
|
|
228
|
+
"cost": float
|
|
229
|
+
}
|
|
230
|
+
"""
|
|
231
|
+
import json
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Prepare form data (multipart)
|
|
235
|
+
data_dict = {
|
|
236
|
+
"api_key": api_key,
|
|
237
|
+
"session_id": session_id,
|
|
238
|
+
"step_number": step_number,
|
|
239
|
+
"task": task,
|
|
240
|
+
"ui_state": ui_state,
|
|
241
|
+
"chat_history": chat_history,
|
|
242
|
+
"config": config
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Convert to JSON string
|
|
246
|
+
data_json = json.dumps(data_dict)
|
|
247
|
+
|
|
248
|
+
# Prepare files dict
|
|
249
|
+
files = {
|
|
250
|
+
"data": ("data.json", data_json, "application/json")
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Add screenshot if provided
|
|
254
|
+
if screenshot_bytes:
|
|
255
|
+
files["screenshot"] = ("screenshot.png", screenshot_bytes, "image/png")
|
|
256
|
+
|
|
257
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
258
|
+
response = await client.post(
|
|
259
|
+
f"{self.base_url}/api/agent/step",
|
|
260
|
+
files=files
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if response.status_code == 200:
|
|
264
|
+
return response.json()
|
|
265
|
+
else:
|
|
266
|
+
error_msg = f"Backend error: HTTP {response.status_code}"
|
|
267
|
+
logger.error(error_msg)
|
|
268
|
+
return {
|
|
269
|
+
"status": "error",
|
|
270
|
+
"message": error_msg,
|
|
271
|
+
"error": error_msg
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
error_msg = f"Failed to execute step: {str(e)}"
|
|
276
|
+
logger.error(error_msg)
|
|
277
|
+
return {
|
|
278
|
+
"status": "error",
|
|
279
|
+
"message": error_msg,
|
|
280
|
+
"error": str(e)
|
|
281
|
+
}
|
|
282
|
+
|
|
193
283
|
|
|
194
284
|
# Singleton instance
|
|
195
285
|
_backend_client = None
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Device state capture utilities.
|
|
3
|
+
Captures UI state and screenshots from Android devices.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import requests
|
|
8
|
+
from typing import Dict, Any, Optional, Tuple
|
|
9
|
+
from adbutils import adb
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("quash-device")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_current_package(serial: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Get the currently focused app package.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
serial: Device serial number
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Package name of current app
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
device = adb.device(serial)
|
|
26
|
+
output = device.shell("dumpsys window windows | grep -E 'mCurrentFocus'")
|
|
27
|
+
# Parse output like: mCurrentFocus=Window{abc123 u0 com.android.settings/com.android.settings.MainActivity}
|
|
28
|
+
if "/" in output:
|
|
29
|
+
package = output.split("/")[0].split()[-1]
|
|
30
|
+
return package
|
|
31
|
+
return "unknown"
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.warning(f"Failed to get current package: {e}")
|
|
34
|
+
return "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_accessibility_tree(serial: str, tcp_port: int = 8080) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Get accessibility tree from Portal app via TCP.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
serial: Device serial number
|
|
43
|
+
tcp_port: Local TCP port for Portal communication
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Accessibility tree XML string
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
device = adb.device(serial)
|
|
50
|
+
local_port = device.forward_port(tcp_port)
|
|
51
|
+
|
|
52
|
+
response = requests.get(
|
|
53
|
+
f"http://localhost:{local_port}/get_a11y_tree",
|
|
54
|
+
timeout=10
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if response.status_code == 200:
|
|
58
|
+
return response.text
|
|
59
|
+
else:
|
|
60
|
+
logger.warning(f"Failed to get accessibility tree: HTTP {response.status_code}")
|
|
61
|
+
return "<hierarchy></hierarchy>"
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning(f"Failed to get accessibility tree: {e}")
|
|
65
|
+
return "<hierarchy></hierarchy>"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def capture_screenshot(serial: str) -> Optional[bytes]:
|
|
69
|
+
"""
|
|
70
|
+
Capture screenshot from device.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
serial: Device serial number
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Screenshot as PNG bytes, or None if failed
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
device = adb.device(serial)
|
|
80
|
+
screenshot_bytes = device.shell("screencap -p", stream=True)
|
|
81
|
+
return screenshot_bytes
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to capture screenshot: {e}")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_device_state(serial: str) -> Tuple[Dict[str, Any], Optional[bytes]]:
|
|
88
|
+
"""
|
|
89
|
+
Get complete device state: UI state and screenshot.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
serial: Device serial number
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple of (ui_state_dict, screenshot_bytes)
|
|
96
|
+
"""
|
|
97
|
+
# Get current package
|
|
98
|
+
current_package = get_current_package(serial)
|
|
99
|
+
|
|
100
|
+
# Get accessibility tree
|
|
101
|
+
a11y_tree = get_accessibility_tree(serial)
|
|
102
|
+
|
|
103
|
+
# Build UI state
|
|
104
|
+
ui_state = {
|
|
105
|
+
"a11y_tree": a11y_tree,
|
|
106
|
+
"phone_state": {
|
|
107
|
+
"package": current_package,
|
|
108
|
+
"activity": "unknown", # Can be added later
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Capture screenshot
|
|
113
|
+
screenshot = capture_screenshot(serial)
|
|
114
|
+
|
|
115
|
+
return ui_state, screenshot
|
quash_mcp/tools/execute.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Execute tool - Run automation tasks via backend
|
|
3
|
-
|
|
2
|
+
Execute tool - Run automation tasks via step-by-step backend communication.
|
|
3
|
+
|
|
4
|
+
V3: Hybrid architecture - AI logic on backend (private), device access local (public).
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
from typing import Dict, Any, Callable, Optional
|
|
7
|
-
from
|
|
8
|
-
from ..backend_client import get_backend_client
|
|
8
|
+
from .execute_v3 import execute_v3
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
async def execute(
|
|
@@ -15,163 +15,17 @@ async def execute(
|
|
|
15
15
|
"""
|
|
16
16
|
Execute an automation task on the connected Android device.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Uses step-by-step execution:
|
|
19
|
+
- Captures device state locally
|
|
20
|
+
- Sends to backend for AI decision
|
|
21
|
+
- Executes actions locally
|
|
22
|
+
- Keeps proprietary AI logic private on backend
|
|
19
23
|
|
|
20
24
|
Args:
|
|
21
25
|
task: Natural language task description
|
|
22
|
-
progress_callback: Optional callback for progress updates
|
|
26
|
+
progress_callback: Optional callback for progress updates
|
|
23
27
|
|
|
24
28
|
Returns:
|
|
25
29
|
Dict with execution result and details
|
|
26
30
|
"""
|
|
27
|
-
|
|
28
|
-
backend = get_backend_client()
|
|
29
|
-
|
|
30
|
-
# Check prerequisites
|
|
31
|
-
if not state.is_device_connected():
|
|
32
|
-
return {
|
|
33
|
-
"status": "error",
|
|
34
|
-
"message": "❌ No device connected. Please run 'connect' first.",
|
|
35
|
-
"prerequisite": "connect"
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if not state.is_configured():
|
|
39
|
-
return {
|
|
40
|
-
"status": "error",
|
|
41
|
-
"message": "❌ Configuration incomplete. Please run 'configure' with your Quash API key.",
|
|
42
|
-
"prerequisite": "configure"
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if not state.portal_ready:
|
|
46
|
-
return {
|
|
47
|
-
"status": "error",
|
|
48
|
-
"message": "⚠️ Portal accessibility service not ready. Please ensure it's enabled on the device.",
|
|
49
|
-
"prerequisite": "connect"
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
# Get API key and config from state
|
|
53
|
-
quash_api_key = state.config["api_key"]
|
|
54
|
-
|
|
55
|
-
# Validate API key with backend
|
|
56
|
-
validation_result = await backend.validate_api_key(quash_api_key)
|
|
57
|
-
|
|
58
|
-
if not validation_result.get("valid", False):
|
|
59
|
-
error_msg = validation_result.get("error", "Invalid API key")
|
|
60
|
-
return {
|
|
61
|
-
"status": "error",
|
|
62
|
-
"message": f"❌ API Key validation failed: {error_msg}",
|
|
63
|
-
"prerequisite": "configure"
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
# Check user credits
|
|
67
|
-
user_info = validation_result.get("user", {})
|
|
68
|
-
credits = user_info.get("credits", 0)
|
|
69
|
-
|
|
70
|
-
if credits <= 0:
|
|
71
|
-
return {
|
|
72
|
-
"status": "error",
|
|
73
|
-
"message": f"❌ Insufficient credits. Current balance: ${credits:.2f}. Please add credits at https://quashbugs.com",
|
|
74
|
-
"user": user_info
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
# Progress callback (for backward compatibility)
|
|
78
|
-
def log_progress(message: str):
|
|
79
|
-
"""Send progress updates."""
|
|
80
|
-
if progress_callback:
|
|
81
|
-
progress_callback(message)
|
|
82
|
-
|
|
83
|
-
log_progress(f"✅ API Key validated - Credits: ${credits:.2f}")
|
|
84
|
-
log_progress(f"👤 User: {user_info.get('name', 'Unknown')}")
|
|
85
|
-
log_progress(f"🚀 Starting task: {task}")
|
|
86
|
-
log_progress(f"📱 Device: {state.device_serial}")
|
|
87
|
-
log_progress(f"🧠 Model: {state.config['model']}")
|
|
88
|
-
log_progress("⚙️ Executing on backend...")
|
|
89
|
-
|
|
90
|
-
try:
|
|
91
|
-
# ============================================================
|
|
92
|
-
# EXECUTE ON BACKEND - ALL AI LOGIC IS PRIVATE
|
|
93
|
-
# The backend handles: LLM setup, agent initialization,
|
|
94
|
-
# execution, pricing, usage tracking, credit deduction
|
|
95
|
-
# ============================================================
|
|
96
|
-
|
|
97
|
-
result = await backend.execute_task(
|
|
98
|
-
api_key=quash_api_key,
|
|
99
|
-
task=task,
|
|
100
|
-
device_serial=state.device_serial,
|
|
101
|
-
config={
|
|
102
|
-
"model": state.config["model"],
|
|
103
|
-
"temperature": state.config["temperature"],
|
|
104
|
-
"vision": state.config["vision"],
|
|
105
|
-
"reasoning": state.config["reasoning"],
|
|
106
|
-
"reflection": state.config["reflection"],
|
|
107
|
-
"debug": state.config["debug"]
|
|
108
|
-
}
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
# Process result
|
|
112
|
-
status = result.get("status")
|
|
113
|
-
message = result.get("message", "")
|
|
114
|
-
steps_taken = result.get("steps_taken", 0)
|
|
115
|
-
final_message = result.get("final_message", "")
|
|
116
|
-
tokens = result.get("tokens", {})
|
|
117
|
-
cost = result.get("cost", 0.0)
|
|
118
|
-
duration = result.get("duration_seconds", 0.0)
|
|
119
|
-
error = result.get("error")
|
|
120
|
-
|
|
121
|
-
# Log usage info
|
|
122
|
-
if tokens and cost:
|
|
123
|
-
total_tokens = tokens.get("total", 0)
|
|
124
|
-
log_progress(f"💰 Usage: {total_tokens} tokens, ${cost:.4f}")
|
|
125
|
-
|
|
126
|
-
# Return formatted result
|
|
127
|
-
if status == "success":
|
|
128
|
-
log_progress(f"✅ Task completed successfully in {steps_taken} steps")
|
|
129
|
-
return {
|
|
130
|
-
"status": "success",
|
|
131
|
-
"steps_taken": steps_taken,
|
|
132
|
-
"final_message": final_message,
|
|
133
|
-
"message": message,
|
|
134
|
-
"tokens": tokens,
|
|
135
|
-
"cost": cost,
|
|
136
|
-
"duration_seconds": duration
|
|
137
|
-
}
|
|
138
|
-
elif status == "failed":
|
|
139
|
-
log_progress(f"❌ Task failed: {final_message}")
|
|
140
|
-
return {
|
|
141
|
-
"status": "failed",
|
|
142
|
-
"steps_taken": steps_taken,
|
|
143
|
-
"final_message": final_message,
|
|
144
|
-
"message": message,
|
|
145
|
-
"tokens": tokens,
|
|
146
|
-
"cost": cost,
|
|
147
|
-
"duration_seconds": duration
|
|
148
|
-
}
|
|
149
|
-
elif status == "interrupted":
|
|
150
|
-
log_progress("⏹️ Task interrupted")
|
|
151
|
-
return {
|
|
152
|
-
"status": "interrupted",
|
|
153
|
-
"message": message
|
|
154
|
-
}
|
|
155
|
-
else: # error
|
|
156
|
-
log_progress(f"💥 Error: {error or message}")
|
|
157
|
-
return {
|
|
158
|
-
"status": "error",
|
|
159
|
-
"message": message,
|
|
160
|
-
"error": error or message
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
except KeyboardInterrupt:
|
|
164
|
-
log_progress("⏹️ Task interrupted by user")
|
|
165
|
-
return {
|
|
166
|
-
"status": "interrupted",
|
|
167
|
-
"message": "⏹️ Task execution interrupted"
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
except Exception as e:
|
|
171
|
-
error_msg = str(e)
|
|
172
|
-
log_progress(f"💥 Error: {error_msg}")
|
|
173
|
-
return {
|
|
174
|
-
"status": "error",
|
|
175
|
-
"message": f"💥 Execution error: {error_msg}",
|
|
176
|
-
"error": error_msg
|
|
177
|
-
}
|
|
31
|
+
return await execute_v3(task=task, progress_callback=progress_callback)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execute tool - Run automation tasks via backend API.
|
|
3
|
+
All AI/agent logic runs on the backend to protect business logic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Any, Callable, Optional
|
|
7
|
+
from ..state import get_state
|
|
8
|
+
from ..backend_client import get_backend_client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def execute(
|
|
12
|
+
task: str,
|
|
13
|
+
progress_callback: Optional[Callable[[str], None]] = None
|
|
14
|
+
) -> Dict[str, Any]:
|
|
15
|
+
"""
|
|
16
|
+
Execute an automation task on the connected Android device.
|
|
17
|
+
|
|
18
|
+
All AI execution happens on the backend - this keeps proprietary logic private.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
task: Natural language task description
|
|
22
|
+
progress_callback: Optional callback for progress updates (not used in V2)
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Dict with execution result and details
|
|
26
|
+
"""
|
|
27
|
+
state = get_state()
|
|
28
|
+
backend = get_backend_client()
|
|
29
|
+
|
|
30
|
+
# Check prerequisites
|
|
31
|
+
if not state.is_device_connected():
|
|
32
|
+
return {
|
|
33
|
+
"status": "error",
|
|
34
|
+
"message": "❌ No device connected. Please run 'connect' first.",
|
|
35
|
+
"prerequisite": "connect"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if not state.is_configured():
|
|
39
|
+
return {
|
|
40
|
+
"status": "error",
|
|
41
|
+
"message": "❌ Configuration incomplete. Please run 'configure' with your Quash API key.",
|
|
42
|
+
"prerequisite": "configure"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if not state.portal_ready:
|
|
46
|
+
return {
|
|
47
|
+
"status": "error",
|
|
48
|
+
"message": "⚠️ Portal accessibility service not ready. Please ensure it's enabled on the device.",
|
|
49
|
+
"prerequisite": "connect"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Get API key and config from state
|
|
53
|
+
quash_api_key = state.config["api_key"]
|
|
54
|
+
|
|
55
|
+
# Validate API key with backend
|
|
56
|
+
validation_result = await backend.validate_api_key(quash_api_key)
|
|
57
|
+
|
|
58
|
+
if not validation_result.get("valid", False):
|
|
59
|
+
error_msg = validation_result.get("error", "Invalid API key")
|
|
60
|
+
return {
|
|
61
|
+
"status": "error",
|
|
62
|
+
"message": f"❌ API Key validation failed: {error_msg}",
|
|
63
|
+
"prerequisite": "configure"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Check user credits
|
|
67
|
+
user_info = validation_result.get("user", {})
|
|
68
|
+
credits = user_info.get("credits", 0)
|
|
69
|
+
|
|
70
|
+
if credits <= 0:
|
|
71
|
+
return {
|
|
72
|
+
"status": "error",
|
|
73
|
+
"message": f"❌ Insufficient credits. Current balance: ${credits:.2f}. Please add credits at https://quashbugs.com",
|
|
74
|
+
"user": user_info
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Progress callback (for backward compatibility)
|
|
78
|
+
def log_progress(message: str):
|
|
79
|
+
"""Send progress updates."""
|
|
80
|
+
if progress_callback:
|
|
81
|
+
progress_callback(message)
|
|
82
|
+
|
|
83
|
+
log_progress(f"✅ API Key validated - Credits: ${credits:.2f}")
|
|
84
|
+
log_progress(f"👤 User: {user_info.get('name', 'Unknown')}")
|
|
85
|
+
log_progress(f"🚀 Starting task: {task}")
|
|
86
|
+
log_progress(f"📱 Device: {state.device_serial}")
|
|
87
|
+
log_progress(f"🧠 Model: {state.config['model']}")
|
|
88
|
+
log_progress("⚙️ Executing on backend...")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# ============================================================
|
|
92
|
+
# EXECUTE ON BACKEND - ALL AI LOGIC IS PRIVATE
|
|
93
|
+
# The backend handles: LLM setup, agent initialization,
|
|
94
|
+
# execution, pricing, usage tracking, credit deduction
|
|
95
|
+
# ============================================================
|
|
96
|
+
|
|
97
|
+
result = await backend.execute_task(
|
|
98
|
+
api_key=quash_api_key,
|
|
99
|
+
task=task,
|
|
100
|
+
device_serial=state.device_serial,
|
|
101
|
+
config={
|
|
102
|
+
"model": state.config["model"],
|
|
103
|
+
"temperature": state.config["temperature"],
|
|
104
|
+
"vision": state.config["vision"],
|
|
105
|
+
"reasoning": state.config["reasoning"],
|
|
106
|
+
"reflection": state.config["reflection"],
|
|
107
|
+
"debug": state.config["debug"]
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Process result
|
|
112
|
+
status = result.get("status")
|
|
113
|
+
message = result.get("message", "")
|
|
114
|
+
steps_taken = result.get("steps_taken", 0)
|
|
115
|
+
final_message = result.get("final_message", "")
|
|
116
|
+
tokens = result.get("tokens", {})
|
|
117
|
+
cost = result.get("cost", 0.0)
|
|
118
|
+
duration = result.get("duration_seconds", 0.0)
|
|
119
|
+
error = result.get("error")
|
|
120
|
+
|
|
121
|
+
# Log usage info
|
|
122
|
+
if tokens and cost:
|
|
123
|
+
total_tokens = tokens.get("total", 0)
|
|
124
|
+
log_progress(f"💰 Usage: {total_tokens} tokens, ${cost:.4f}")
|
|
125
|
+
|
|
126
|
+
# Return formatted result
|
|
127
|
+
if status == "success":
|
|
128
|
+
log_progress(f"✅ Task completed successfully in {steps_taken} steps")
|
|
129
|
+
return {
|
|
130
|
+
"status": "success",
|
|
131
|
+
"steps_taken": steps_taken,
|
|
132
|
+
"final_message": final_message,
|
|
133
|
+
"message": message,
|
|
134
|
+
"tokens": tokens,
|
|
135
|
+
"cost": cost,
|
|
136
|
+
"duration_seconds": duration
|
|
137
|
+
}
|
|
138
|
+
elif status == "failed":
|
|
139
|
+
log_progress(f"❌ Task failed: {final_message}")
|
|
140
|
+
return {
|
|
141
|
+
"status": "failed",
|
|
142
|
+
"steps_taken": steps_taken,
|
|
143
|
+
"final_message": final_message,
|
|
144
|
+
"message": message,
|
|
145
|
+
"tokens": tokens,
|
|
146
|
+
"cost": cost,
|
|
147
|
+
"duration_seconds": duration
|
|
148
|
+
}
|
|
149
|
+
elif status == "interrupted":
|
|
150
|
+
log_progress("⏹️ Task interrupted")
|
|
151
|
+
return {
|
|
152
|
+
"status": "interrupted",
|
|
153
|
+
"message": message
|
|
154
|
+
}
|
|
155
|
+
else: # error
|
|
156
|
+
log_progress(f"💥 Error: {error or message}")
|
|
157
|
+
return {
|
|
158
|
+
"status": "error",
|
|
159
|
+
"message": message,
|
|
160
|
+
"error": error or message
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
except KeyboardInterrupt:
|
|
164
|
+
log_progress("⏹️ Task interrupted by user")
|
|
165
|
+
return {
|
|
166
|
+
"status": "interrupted",
|
|
167
|
+
"message": "⏹️ Task execution interrupted"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
error_msg = str(e)
|
|
172
|
+
log_progress(f"💥 Error: {error_msg}")
|
|
173
|
+
return {
|
|
174
|
+
"status": "error",
|
|
175
|
+
"message": f"💥 Execution error: {error_msg}",
|
|
176
|
+
"error": error_msg
|
|
177
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execute tool V3 - Step-by-step execution with local device access.
|
|
3
|
+
|
|
4
|
+
AI logic runs on backend (private), device access happens locally (public).
|
|
5
|
+
This hybrid approach keeps proprietary code private while allowing local device control.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Dict, Any, Callable, Optional
|
|
11
|
+
from ..state import get_state
|
|
12
|
+
from ..backend_client import get_backend_client
|
|
13
|
+
from ..device.state_capture import get_device_state
|
|
14
|
+
from ..device.adb_tools import AdbTools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def execute_v3(
|
|
18
|
+
task: str,
|
|
19
|
+
progress_callback: Optional[Callable[[str], None]] = None
|
|
20
|
+
) -> Dict[str, Any]:
|
|
21
|
+
"""
|
|
22
|
+
Execute automation task using step-by-step backend communication.
|
|
23
|
+
|
|
24
|
+
Each step:
|
|
25
|
+
1. Capture device state locally (UI + optional screenshot)
|
|
26
|
+
2. Send to backend for AI decision
|
|
27
|
+
3. Execute returned action locally
|
|
28
|
+
4. Repeat until complete
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
task: Natural language task description
|
|
32
|
+
progress_callback: Optional callback for progress updates
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dict with execution result and details
|
|
36
|
+
"""
|
|
37
|
+
state = get_state()
|
|
38
|
+
backend = get_backend_client()
|
|
39
|
+
|
|
40
|
+
# Check prerequisites
|
|
41
|
+
if not state.is_device_connected():
|
|
42
|
+
return {
|
|
43
|
+
"status": "error",
|
|
44
|
+
"message": "❌ No device connected. Please run 'connect' first.",
|
|
45
|
+
"prerequisite": "connect"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if not state.is_configured():
|
|
49
|
+
return {
|
|
50
|
+
"status": "error",
|
|
51
|
+
"message": "❌ Configuration incomplete. Please run 'configure' with your Quash API key.",
|
|
52
|
+
"prerequisite": "configure"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if not state.portal_ready:
|
|
56
|
+
return {
|
|
57
|
+
"status": "error",
|
|
58
|
+
"message": "⚠️ Portal accessibility service not ready. Please ensure it's enabled on the device.",
|
|
59
|
+
"prerequisite": "connect"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Get API key and config
|
|
63
|
+
quash_api_key = state.config["api_key"]
|
|
64
|
+
config = {
|
|
65
|
+
"model": state.config["model"],
|
|
66
|
+
"temperature": state.config["temperature"],
|
|
67
|
+
"vision": state.config["vision"],
|
|
68
|
+
"reasoning": state.config["reasoning"],
|
|
69
|
+
"reflection": state.config["reflection"],
|
|
70
|
+
"debug": state.config["debug"]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Validate API key
|
|
74
|
+
validation_result = await backend.validate_api_key(quash_api_key)
|
|
75
|
+
|
|
76
|
+
if not validation_result.get("valid", False):
|
|
77
|
+
error_msg = validation_result.get("error", "Invalid API key")
|
|
78
|
+
return {
|
|
79
|
+
"status": "error",
|
|
80
|
+
"message": f"❌ API Key validation failed: {error_msg}",
|
|
81
|
+
"prerequisite": "configure"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Check credits
|
|
85
|
+
user_info = validation_result.get("user", {})
|
|
86
|
+
credits = user_info.get("credits", 0)
|
|
87
|
+
|
|
88
|
+
if credits <= 0:
|
|
89
|
+
return {
|
|
90
|
+
"status": "error",
|
|
91
|
+
"message": f"❌ Insufficient credits. Current balance: ${credits:.2f}",
|
|
92
|
+
"user": user_info
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Progress logging helper
|
|
96
|
+
def log_progress(message: str):
|
|
97
|
+
if progress_callback:
|
|
98
|
+
progress_callback(message)
|
|
99
|
+
|
|
100
|
+
log_progress(f"✅ API Key validated - Credits: ${credits:.2f}")
|
|
101
|
+
log_progress(f"👤 User: {user_info.get('name', 'Unknown')}")
|
|
102
|
+
log_progress(f"🚀 Starting task: {task}")
|
|
103
|
+
log_progress(f"📱 Device: {state.device_serial}")
|
|
104
|
+
log_progress(f"🧠 Model: {config['model']}")
|
|
105
|
+
|
|
106
|
+
# Initialize execution
|
|
107
|
+
start_time = time.time()
|
|
108
|
+
session_id = f"session_{uuid.uuid4().hex[:12]}"
|
|
109
|
+
step_number = 0
|
|
110
|
+
chat_history = []
|
|
111
|
+
total_tokens = {"prompt": 0, "completion": 0, "total": 0}
|
|
112
|
+
total_cost = 0.0
|
|
113
|
+
|
|
114
|
+
# Initialize local ADB tools for code execution
|
|
115
|
+
adb_tools = AdbTools(serial=state.device_serial, use_tcp=True)
|
|
116
|
+
|
|
117
|
+
# Code executor namespace
|
|
118
|
+
executor_globals = {
|
|
119
|
+
"__builtins__": __builtins__,
|
|
120
|
+
"adb_tools": adb_tools
|
|
121
|
+
}
|
|
122
|
+
executor_locals = {}
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# ============================================================
|
|
126
|
+
# STEP-BY-STEP EXECUTION LOOP
|
|
127
|
+
# ============================================================
|
|
128
|
+
while step_number < 15: # Max 15 steps
|
|
129
|
+
step_number += 1
|
|
130
|
+
log_progress(f"🧠 Step {step_number}: Thinking...")
|
|
131
|
+
|
|
132
|
+
# 1. Capture device state
|
|
133
|
+
try:
|
|
134
|
+
ui_state_dict, screenshot_bytes = get_device_state(state.device_serial)
|
|
135
|
+
|
|
136
|
+
# Only include screenshot if vision is enabled
|
|
137
|
+
if not config["vision"]:
|
|
138
|
+
screenshot_bytes = None
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
log_progress(f"⚠️ Warning: Failed to capture device state: {e}")
|
|
142
|
+
ui_state_dict = {
|
|
143
|
+
"a11y_tree": "<hierarchy></hierarchy>",
|
|
144
|
+
"phone_state": {"package": "unknown"}
|
|
145
|
+
}
|
|
146
|
+
screenshot_bytes = None
|
|
147
|
+
|
|
148
|
+
# 2. Send to backend for AI decision
|
|
149
|
+
step_result = await backend.execute_step(
|
|
150
|
+
api_key=quash_api_key,
|
|
151
|
+
session_id=session_id,
|
|
152
|
+
step_number=step_number,
|
|
153
|
+
task=task,
|
|
154
|
+
ui_state=ui_state_dict,
|
|
155
|
+
chat_history=chat_history,
|
|
156
|
+
config=config,
|
|
157
|
+
screenshot_bytes=screenshot_bytes
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Handle backend errors
|
|
161
|
+
if "error" in step_result:
|
|
162
|
+
log_progress(f"💥 Backend error: {step_result['message']}")
|
|
163
|
+
return {
|
|
164
|
+
"status": "error",
|
|
165
|
+
"message": step_result["message"],
|
|
166
|
+
"error": step_result["error"],
|
|
167
|
+
"steps_taken": step_number,
|
|
168
|
+
"tokens": total_tokens,
|
|
169
|
+
"cost": total_cost,
|
|
170
|
+
"duration_seconds": time.time() - start_time
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Update usage tracking
|
|
174
|
+
step_tokens = step_result.get("tokens_used", {})
|
|
175
|
+
step_cost = step_result.get("cost", 0.0)
|
|
176
|
+
|
|
177
|
+
total_tokens["prompt"] += step_tokens.get("prompt", 0)
|
|
178
|
+
total_tokens["completion"] += step_tokens.get("completion", 0)
|
|
179
|
+
total_tokens["total"] += step_tokens.get("total", 0)
|
|
180
|
+
total_cost += step_cost
|
|
181
|
+
|
|
182
|
+
# Get action from backend
|
|
183
|
+
action = step_result.get("action", {})
|
|
184
|
+
action_type = action.get("type")
|
|
185
|
+
code = action.get("code")
|
|
186
|
+
reasoning = action.get("reasoning")
|
|
187
|
+
|
|
188
|
+
# Log reasoning
|
|
189
|
+
if reasoning:
|
|
190
|
+
log_progress(f"🤔 Reasoning: {reasoning}")
|
|
191
|
+
|
|
192
|
+
# Update chat history
|
|
193
|
+
assistant_response = step_result.get("assistant_response", "")
|
|
194
|
+
chat_history.append({"role": "assistant", "content": assistant_response})
|
|
195
|
+
|
|
196
|
+
# 3. Check if task is complete
|
|
197
|
+
if step_result.get("completed", False):
|
|
198
|
+
success = step_result.get("success", False)
|
|
199
|
+
final_message = step_result.get("final_message", "Task completed")
|
|
200
|
+
|
|
201
|
+
duration = time.time() - start_time
|
|
202
|
+
|
|
203
|
+
if success:
|
|
204
|
+
log_progress(f"✅ Task completed successfully in {step_number} steps")
|
|
205
|
+
log_progress(f"💰 Usage: {total_tokens['total']} tokens, ${total_cost:.4f}")
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"status": "success",
|
|
209
|
+
"steps_taken": step_number,
|
|
210
|
+
"final_message": final_message,
|
|
211
|
+
"message": f"✅ Success: {final_message}",
|
|
212
|
+
"tokens": total_tokens,
|
|
213
|
+
"cost": total_cost,
|
|
214
|
+
"duration_seconds": duration
|
|
215
|
+
}
|
|
216
|
+
else:
|
|
217
|
+
log_progress(f"❌ Task failed: {final_message}")
|
|
218
|
+
log_progress(f"💰 Usage: {total_tokens['total']} tokens, ${total_cost:.4f}")
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"status": "failed",
|
|
222
|
+
"steps_taken": step_number,
|
|
223
|
+
"final_message": final_message,
|
|
224
|
+
"message": f"❌ Failed: {final_message}",
|
|
225
|
+
"tokens": total_tokens,
|
|
226
|
+
"cost": total_cost,
|
|
227
|
+
"duration_seconds": duration
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# 4. Execute action locally
|
|
231
|
+
if code and action_type == "execute_code":
|
|
232
|
+
log_progress(f"⚡ Executing action...")
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
# Execute code in sandbox
|
|
236
|
+
exec(code, executor_globals, executor_locals)
|
|
237
|
+
|
|
238
|
+
# Get execution result
|
|
239
|
+
execution_output = executor_locals.get("_result", "Code executed successfully")
|
|
240
|
+
|
|
241
|
+
# Add execution result to chat history
|
|
242
|
+
chat_history.append({
|
|
243
|
+
"role": "user",
|
|
244
|
+
"content": f"Execution Result:\n```\n{execution_output}\n```"
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
error_msg = f"Error during execution: {str(e)}"
|
|
249
|
+
log_progress(f"💥 Action failed: {error_msg}")
|
|
250
|
+
|
|
251
|
+
# Add error to chat history
|
|
252
|
+
chat_history.append({
|
|
253
|
+
"role": "user",
|
|
254
|
+
"content": f"Execution Result:\n```\n{error_msg}\n```"
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
else:
|
|
258
|
+
# No code to execute
|
|
259
|
+
log_progress("⚠️ No action code provided by backend")
|
|
260
|
+
chat_history.append({
|
|
261
|
+
"role": "user",
|
|
262
|
+
"content": "No code was provided. Please provide code to execute."
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
# Max steps reached
|
|
266
|
+
log_progress(f"⚠️ Reached maximum steps ({step_number})")
|
|
267
|
+
log_progress(f"💰 Usage: {total_tokens['total']} tokens, ${total_cost:.4f}")
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"status": "failed",
|
|
271
|
+
"steps_taken": step_number,
|
|
272
|
+
"final_message": f"Reached maximum step limit of {step_number}",
|
|
273
|
+
"message": "❌ Failed: Maximum steps reached",
|
|
274
|
+
"tokens": total_tokens,
|
|
275
|
+
"cost": total_cost,
|
|
276
|
+
"duration_seconds": time.time() - start_time
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
log_progress("⏹️ Task interrupted by user")
|
|
281
|
+
return {
|
|
282
|
+
"status": "interrupted",
|
|
283
|
+
"message": "⏹️ Task execution interrupted",
|
|
284
|
+
"steps_taken": step_number,
|
|
285
|
+
"tokens": total_tokens,
|
|
286
|
+
"cost": total_cost,
|
|
287
|
+
"duration_seconds": time.time() - start_time
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
error_msg = str(e)
|
|
292
|
+
log_progress(f"💥 Error: {error_msg}")
|
|
293
|
+
return {
|
|
294
|
+
"status": "error",
|
|
295
|
+
"message": f"💥 Execution error: {error_msg}",
|
|
296
|
+
"error": error_msg,
|
|
297
|
+
"steps_taken": step_number,
|
|
298
|
+
"tokens": total_tokens,
|
|
299
|
+
"cost": total_cost,
|
|
300
|
+
"duration_seconds": time.time() - start_time
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
finally:
|
|
304
|
+
# Cleanup TCP forwarding
|
|
305
|
+
if adb_tools:
|
|
306
|
+
adb_tools.teardown_tcp_forward()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quash-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Model Context Protocol server for Quash - AI-powered mobile automation agent
|
|
5
5
|
Project-URL: Homepage, https://quashbugs.com
|
|
6
6
|
Project-URL: Repository, https://github.com/quash/quash-mcp
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
quash_mcp/__init__.py,sha256=LImiWCRgjAbb5DZXBq2DktUEAbftvnO61Vil4Ayun9A,39
|
|
2
2
|
quash_mcp/__main__.py,sha256=WCg5OlnXhr6i0XJHAUGpbhliMy3qE2SJkFzVD4wO-lw,239
|
|
3
|
-
quash_mcp/backend_client.py,sha256=
|
|
3
|
+
quash_mcp/backend_client.py,sha256=cXZQ2OF_BZaHLeEVpAiPEVumQJFBYT9bO6d1RIBf3Ko,9867
|
|
4
4
|
quash_mcp/server.py,sha256=scUGnplxjsvyYLK2q6hrjl-5Chkdnat9pODDtLzsQFY,15519
|
|
5
5
|
quash_mcp/state.py,sha256=Tnt795GnZcas-h62Y6KYyIZVopeoWPM0TbRwOeVFYj4,4394
|
|
6
6
|
quash_mcp/device/__init__.py,sha256=6e8CtHolt-vJKPxZUU_Vsd6-QGqos9VrFykaLTT90rk,772
|
|
7
7
|
quash_mcp/device/adb_tools.py,sha256=Q0HIZHLBswaD94Qa7AA2VtjY31EZ68l0YClJDmMjOBU,3778
|
|
8
8
|
quash_mcp/device/portal.py,sha256=sDLJOruUwwNNxIDriiXB4vT0BZYILidgzVgdhHCEkDY,5241
|
|
9
|
+
quash_mcp/device/state_capture.py,sha256=fftwdS-2-EQtNdecyXUegobSVguaxUYSMsuuCPzzdZc,3021
|
|
9
10
|
quash_mcp/tools/__init__.py,sha256=r4fMAjHDjHUbimRwYW7VYUDkQHs12UVsG_IBmWpeX9s,249
|
|
10
11
|
quash_mcp/tools/build.py,sha256=M6tGXWrQNkdtCYYrK14gUaoufQvyoor_hNN0lBPSVHY,30321
|
|
11
12
|
quash_mcp/tools/build_old.py,sha256=6M9gaqZ_dX4B7UFTxSMD8T1BX0zEwQUL7RJ8ItNfB54,6016
|
|
12
13
|
quash_mcp/tools/configure.py,sha256=cv4RTolu6qae-XzyACSJUDrALfd0gYC-XE5s66_zfNk,4439
|
|
13
14
|
quash_mcp/tools/connect.py,sha256=Kc7RGRUgtd2sR_bv6U4CB4kWSaLfsDc5kBo9u4FEjzs,4799
|
|
14
|
-
quash_mcp/tools/execute.py,sha256=
|
|
15
|
+
quash_mcp/tools/execute.py,sha256=kR3VzIl31Lek-js4Hgxs-S_ls4YwKnbqkt79KFbvFuM,909
|
|
16
|
+
quash_mcp/tools/execute_v2_backup.py,sha256=waWnaD0dEVcOJgRBbqZo3HnxME1s6YUOn8aRbm4R3X4,6081
|
|
17
|
+
quash_mcp/tools/execute_v3.py,sha256=z8aNTyA9bdfc3Ub4LBd1HWaUWyTUEwsWc557USlHI9M,11089
|
|
15
18
|
quash_mcp/tools/runsuite.py,sha256=gohLk9FpN8v7F0a69fspqOqUexTcslpYf3qU-iIZZ3s,7220
|
|
16
19
|
quash_mcp/tools/usage.py,sha256=g76A6FO36fThoyRFG7q92QmS3Kh1pIKOrhYOzUdIubA,1155
|
|
17
|
-
quash_mcp-0.2.
|
|
18
|
-
quash_mcp-0.2.
|
|
19
|
-
quash_mcp-0.2.
|
|
20
|
-
quash_mcp-0.2.
|
|
20
|
+
quash_mcp-0.2.3.dist-info/METADATA,sha256=qcOMslJU2YOtANv-dC6OjVYRyz8rBA5zP9u8EE01O30,8129
|
|
21
|
+
quash_mcp-0.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
22
|
+
quash_mcp-0.2.3.dist-info/entry_points.txt,sha256=9sbDxrx0ApGDVRS-IE3mQgSao3DwKnnV_k-_ipFn9QI,52
|
|
23
|
+
quash_mcp-0.2.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|