quash-mcp 0.2.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.

Potentially problematic release.


This version of quash-mcp might be problematic. Click here for more details.

quash_mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Quash MCP Server - Source Package"""
quash_mcp/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Enable running mahoraga-mcp as a module: python -m mahoraga_mcp
4
+ This allows the package to work in any Python environment where it's installed.
5
+ """
6
+
7
+ from .server import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,203 @@
1
+ """
2
+ Backend API Client for Quash MCP.
3
+ Handles all communication with the Quash backend API.
4
+ """
5
+
6
+ import os
7
+ import httpx
8
+ from typing import Dict, Any, Optional
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BackendClient:
15
+ """Client for communicating with Quash backend API."""
16
+
17
+ def __init__(self):
18
+ # Get backend URL from environment variable, default to localhost for development
19
+ self.base_url = os.getenv("MAHORAGA_BACKEND_URL", "http://localhost:8000")
20
+ self.timeout = 300.0 # 5 minutes for long-running LLM calls
21
+ logger.info(f"🔧 Backend client initialized: URL={self.base_url}")
22
+
23
+ async def validate_api_key(self, api_key: str) -> Dict[str, Any]:
24
+ """
25
+ Validate quash_api_key and check user credits.
26
+
27
+ Args:
28
+ api_key: The mahoraga API key to validate
29
+
30
+ Returns:
31
+ Dict with validation result:
32
+ {
33
+ "valid": bool,
34
+ "user": {"email": str, "name": str, "credits": float},
35
+ "openrouter_api_key": str,
36
+ "error": str (if invalid)
37
+ }
38
+ """
39
+ try:
40
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
41
+ response = await client.post(
42
+ f"{self.base_url}/api/validate",
43
+ json={"api_key": api_key}
44
+ )
45
+
46
+ if response.status_code == 200:
47
+ return response.json()
48
+ else:
49
+ return {
50
+ "valid": False,
51
+ "error": f"API error: {response.status_code}"
52
+ }
53
+
54
+ except Exception as e:
55
+ logger.error(f"Failed to validate API key: {e}")
56
+ return {
57
+ "valid": False,
58
+ "error": f"Connection error: {str(e)}"
59
+ }
60
+
61
+ async def execute_task(
62
+ self,
63
+ api_key: str,
64
+ task: str,
65
+ device_serial: str,
66
+ config: Dict[str, Any]
67
+ ) -> Dict[str, Any]:
68
+ """
69
+ Execute task with Quash agent on backend (V2 - AI on backend).
70
+
71
+ Args:
72
+ api_key: Quash API key
73
+ task: Task description
74
+ device_serial: Device serial number
75
+ config: Execution configuration (model, temp, vision, reasoning, etc.)
76
+
77
+ Returns:
78
+ Dict with execution result:
79
+ {
80
+ "status": "success"|"failed"|"error"|"interrupted",
81
+ "message": str,
82
+ "steps_taken": int,
83
+ "final_message": str,
84
+ "tokens": {"prompt": int, "completion": int, "total": int},
85
+ "cost": float,
86
+ "duration_seconds": float,
87
+ "error": str (if error)
88
+ }
89
+ """
90
+ logger.info(f"🚀 Executing task on backend: {task[:50]}...")
91
+
92
+ try:
93
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
94
+ response = await client.post(
95
+ f"{self.base_url}/api/agent/execute",
96
+ json={
97
+ "api_key": api_key,
98
+ "task": task,
99
+ "device_serial": device_serial,
100
+ "config": config
101
+ }
102
+ )
103
+
104
+ if response.status_code == 200:
105
+ result = response.json()
106
+ logger.info(f"✅ Backend execution completed: {result['status']}")
107
+ return result
108
+ else:
109
+ error_msg = f"Backend error: HTTP {response.status_code}"
110
+ logger.error(error_msg)
111
+ return {
112
+ "status": "error",
113
+ "message": error_msg,
114
+ "error": error_msg
115
+ }
116
+
117
+ except Exception as e:
118
+ error_msg = f"Failed to execute on backend: {str(e)}"
119
+ logger.error(error_msg)
120
+ return {
121
+ "status": "error",
122
+ "message": error_msg,
123
+ "error": str(e)
124
+ }
125
+
126
+ async def log_execution(
127
+ self,
128
+ api_key: str,
129
+ execution_id: str,
130
+ task: str,
131
+ device_serial: str,
132
+ status: str,
133
+ tokens: Optional[Dict[str, int]] = None,
134
+ cost: Optional[float] = None,
135
+ error: Optional[str] = None,
136
+ config: Optional[Dict[str, Any]] = None,
137
+ duration_seconds: Optional[float] = None
138
+ ) -> Dict[str, Any]:
139
+ """
140
+ Log execution completion and usage to backend.
141
+
142
+ Args:
143
+ api_key: Quash API key
144
+ execution_id: Unique execution identifier
145
+ task: Task description
146
+ device_serial: Device serial number
147
+ status: "completed", "failed", or "interrupted"
148
+ tokens: Token usage dict
149
+ cost: Execution cost in USD
150
+ error: Error message if failed
151
+ config: Execution configuration (model, temp, vision, etc.)
152
+ duration_seconds: Time taken to complete
153
+
154
+ Returns:
155
+ Dict with logging result:
156
+ {
157
+ "logged": bool,
158
+ "credits_deducted": float,
159
+ "new_balance": float,
160
+ "error": str (if failed)
161
+ }
162
+ """
163
+ logger.info(f"📊 Logging execution - Cost: ${cost}, Status: {status}")
164
+
165
+ try:
166
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
167
+ response = await client.post(
168
+ f"{self.base_url}/api/execution/complete",
169
+ json={
170
+ "api_key": api_key,
171
+ "execution_id": execution_id,
172
+ "task": task,
173
+ "device_serial": device_serial,
174
+ "status": status,
175
+ "tokens": tokens,
176
+ "cost": cost,
177
+ "error": error,
178
+ "config": config,
179
+ "duration_seconds": duration_seconds
180
+ }
181
+ )
182
+
183
+ if response.status_code == 200:
184
+ return response.json()
185
+ else:
186
+ logger.warning(f"Failed to log execution: {response.status_code}")
187
+ return {"logged": False, "error": f"HTTP {response.status_code}"}
188
+
189
+ except Exception as e:
190
+ logger.error(f"Failed to log execution: {e}")
191
+ return {"logged": False, "error": str(e)}
192
+
193
+
194
+ # Singleton instance
195
+ _backend_client = None
196
+
197
+
198
+ def get_backend_client() -> BackendClient:
199
+ """Get the global backend client instance."""
200
+ global _backend_client
201
+ if _backend_client is None:
202
+ _backend_client = BackendClient()
203
+ return _backend_client
quash_mcp/server.py ADDED
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Quash MCP Server
4
+ A Model Context Protocol server for mobile automation testing with Quash.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import Any
10
+ from pathlib import Path
11
+ from dotenv import load_dotenv
12
+ from mcp.server import Server
13
+ from mcp.server.stdio import stdio_server
14
+ from mcp.types import Tool, TextContent
15
+
16
+ # Load .env file from project root
17
+ project_root = Path(__file__).parent.parent
18
+ env_file = project_root / ".env"
19
+ load_dotenv(env_file)
20
+
21
+ from .tools.build import build
22
+ from .tools.connect import connect
23
+ from .tools.configure import configure
24
+ from .tools.execute import execute
25
+ from .tools.runsuite import runsuite
26
+ from .tools.usage import usage
27
+ from .state import get_state
28
+
29
+ # Setup logging
30
+ logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger("quash-mcp")
32
+
33
+ # Create MCP server instance
34
+ app = Server("quash-mcp")
35
+
36
+
37
+ @app.list_tools()
38
+ async def list_tools() -> list[Tool]:
39
+ """List all available Quash tools with dynamic state information."""
40
+
41
+ # Get current state
42
+ state = get_state()
43
+
44
+ # Build dynamic descriptions
45
+
46
+ # CONNECT tool description
47
+ connect_desc = ("Connect to an Android device or emulator. "
48
+ "Auto-detects single device or allows selection from multiple devices. "
49
+ "Verifies connectivity and checks/installs Quash Portal accessibility service.")
50
+
51
+ if state.is_device_connected():
52
+ connect_desc += f"\n\n📱 CURRENT DEVICE:\n"
53
+ connect_desc += f" • Serial: {state.device_serial}\n"
54
+ if state.device_info:
55
+ connect_desc += f" • Model: {state.device_info.get('model', 'Unknown')}\n"
56
+ connect_desc += f" • Android: {state.device_info.get('android_version', 'Unknown')}\n"
57
+ connect_desc += f" • Portal: {'✓ Ready' if state.portal_ready else '✗ Not Ready'}"
58
+ else:
59
+ connect_desc += "\n\n📱 CURRENT DEVICE: Not connected"
60
+
61
+ # CONFIGURE tool description
62
+ configure_desc = ("Configure Quash agent execution parameters. "
63
+ "Set API key, model, temperature, max steps, and enable/disable vision, reasoning, and reflection features. "
64
+ "Only updates parameters that are provided.")
65
+
66
+ # Mask API key for display (show first 10 and last 6 chars)
67
+ api_key = state.config.get('api_key')
68
+ if api_key:
69
+ if len(api_key) < 20:
70
+ masked_key = api_key[:4] + "..." + api_key[-4:]
71
+ else:
72
+ masked_key = api_key[:10] + "..." + api_key[-6:]
73
+ api_key_display = masked_key
74
+ else:
75
+ api_key_display = "✗ Not Set"
76
+
77
+ configure_desc += f"\n\n⚙️ CURRENT CONFIGURATION:\n"
78
+ configure_desc += f" • API Key: {api_key_display}\n"
79
+ configure_desc += f" • Model: {state.config.get('model', 'anthropic/claude-sonnet-4')}\n"
80
+ configure_desc += f" • Temperature: {state.config.get('temperature', 0.2)}\n"
81
+ configure_desc += f" • Max Steps: {state.config.get('max_steps', 15)}\n"
82
+ configure_desc += f" • Vision: {'✓ Enabled' if state.config.get('vision') else '✗ Disabled'}\n"
83
+ configure_desc += f" • Reasoning: {'✓ Enabled' if state.config.get('reasoning') else '✗ Disabled'}\n"
84
+ configure_desc += f" • Reflection: {'✓ Enabled' if state.config.get('reflection') else '✗ Disabled'}\n"
85
+ configure_desc += f" • Debug: {'✓ Enabled' if state.config.get('debug') else '✗ Disabled'}"
86
+
87
+ # RUNSUITE tool description with latest execution
88
+ runsuite_desc = ("Execute a suite of tasks in sequence. "
89
+ "Runs multiple tasks with support for retries, failure handling, and wait times. "
90
+ "Provides detailed execution summary with pass rates and task-by-task results. "
91
+ "Requires device to be connected and configuration to be set.")
92
+
93
+ # Show latest suite execution if available
94
+ if state.latest_suite:
95
+ suite = state.latest_suite
96
+ runsuite_desc += f"\n\n📋 LATEST SUITE: {suite['suite_name']}\n"
97
+ runsuite_desc += f" • Status: {suite['status'].upper()}\n"
98
+ runsuite_desc += f" • Pass Rate: {suite['pass_rate']}%\n"
99
+ runsuite_desc += f" • Duration: {suite['duration_seconds']}s\n"
100
+ runsuite_desc += f" • Tasks: {suite['completed_tasks']}/{suite['total_tasks']} completed\n"
101
+
102
+ # Show individual task results
103
+ runsuite_desc += "\n Task Results:\n"
104
+ for task_result in suite.get('task_results', []):
105
+ task_num = task_result['task_number']
106
+ task_status = task_result['status']
107
+ task_prompt = task_result.get('prompt', 'No prompt')[:40]
108
+
109
+ # Status indicator
110
+ if task_status == 'completed':
111
+ indicator = "✅"
112
+ elif task_status == 'failed':
113
+ indicator = "❌"
114
+ else:
115
+ indicator = "⏭️"
116
+
117
+ runsuite_desc += f" {indicator} Task {task_num}: {task_prompt}... - {task_status.upper()}\n"
118
+ else:
119
+ runsuite_desc += "\n\n📋 LATEST SUITE: No suites executed yet"
120
+
121
+ # USAGE tool description
122
+ usage_desc = ("View usage statistics and costs for your Quash executions. "
123
+ "All usage tracking happens on the backend for security. "
124
+ "Directs you to the web dashboard for detailed statistics.")
125
+
126
+ return [
127
+ Tool(
128
+ name="build",
129
+ description="Setup and verify all dependencies required for Quash mobile automation. "
130
+ "Checks Python version, ADB installation, Quash package, and Portal APK. "
131
+ "Attempts to auto-install missing dependencies where possible.",
132
+ inputSchema={
133
+ "type": "object",
134
+ "properties": {},
135
+ "required": []
136
+ }
137
+ ),
138
+ Tool(
139
+ name="connect",
140
+ description=connect_desc,
141
+ inputSchema={
142
+ "type": "object",
143
+ "properties": {
144
+ "device_serial": {
145
+ "type": "string",
146
+ "description": "Device serial number (optional - auto-detects if only one device)"
147
+ }
148
+ },
149
+ "required": []
150
+ }
151
+ ),
152
+ Tool(
153
+ name="configure",
154
+ description=configure_desc,
155
+ inputSchema={
156
+ "type": "object",
157
+ "properties": {
158
+ "quash_api_key": {
159
+ "type": "string",
160
+ "description": "Quash API key for authentication and access"
161
+ },
162
+ "model": {
163
+ "type": "string",
164
+ "description": "LLM model name (e.g., 'openai/gpt-4o', 'anthropic/claude-3.5-sonnet')"
165
+ },
166
+ "temperature": {
167
+ "type": "number",
168
+ "description": "Temperature for LLM sampling (0-2, default 0.2)"
169
+ },
170
+ "max_steps": {
171
+ "type": "integer",
172
+ "description": "Maximum number of execution steps (default 15)"
173
+ },
174
+ "vision": {
175
+ "type": "boolean",
176
+ "description": "Enable vision capabilities using screenshots (default false)"
177
+ },
178
+ "reasoning": {
179
+ "type": "boolean",
180
+ "description": "Enable planning with reasoning for complex tasks (default false)"
181
+ },
182
+ "reflection": {
183
+ "type": "boolean",
184
+ "description": "Enable reflection for self-improvement (default false)"
185
+ },
186
+ "debug": {
187
+ "type": "boolean",
188
+ "description": "Enable verbose debug logging (default false)"
189
+ }
190
+ },
191
+ "required": []
192
+ }
193
+ ),
194
+ Tool(
195
+ name="execute",
196
+ description="Execute a mobile automation task on the connected Android device. "
197
+ "Takes natural language instructions and performs the task using AI agents. "
198
+ "Provides live progress updates during execution. "
199
+ "Requires device to be connected and configuration to be set.",
200
+ inputSchema={
201
+ "type": "object",
202
+ "properties": {
203
+ "task": {
204
+ "type": "string",
205
+ "description": "Natural language description of the task to perform (e.g., 'Open Settings and navigate to WiFi')"
206
+ }
207
+ },
208
+ "required": ["task"]
209
+ }
210
+ ),
211
+ Tool(
212
+ name="runsuite",
213
+ description=runsuite_desc,
214
+ inputSchema={
215
+ "type": "object",
216
+ "properties": {
217
+ "suite_name": {
218
+ "type": "string",
219
+ "description": "Name of the test suite being executed"
220
+ },
221
+ "tasks": {
222
+ "type": "array",
223
+ "description": "Array of task definitions to execute in sequence",
224
+ "items": {
225
+ "type": "object",
226
+ "properties": {
227
+ "prompt": {
228
+ "type": "string",
229
+ "description": "Task instruction (required)"
230
+ },
231
+ "type": {
232
+ "type": "string",
233
+ "description": "Task type: 'setup', 'test', or 'teardown' (optional, default 'test')",
234
+ "enum": ["setup", "test", "teardown"]
235
+ },
236
+ "retries": {
237
+ "type": "integer",
238
+ "description": "Number of retry attempts on failure (optional, default 0)"
239
+ },
240
+ "continueOnFailure": {
241
+ "type": "boolean",
242
+ "description": "Continue suite execution if this task fails (optional, default false)"
243
+ },
244
+ "waitBefore": {
245
+ "type": "integer",
246
+ "description": "Seconds to wait before executing task (optional, default 0)"
247
+ }
248
+ },
249
+ "required": ["prompt"]
250
+ }
251
+ }
252
+ },
253
+ "required": ["suite_name", "tasks"]
254
+ }
255
+ ),
256
+ Tool(
257
+ name="usage",
258
+ description=usage_desc,
259
+ inputSchema={
260
+ "type": "object",
261
+ "properties": {
262
+ "api_key": {
263
+ "type": "string",
264
+ "description": "Specific API key to query (optional - shows all if not provided)"
265
+ },
266
+ "show_recent": {
267
+ "type": "integer",
268
+ "description": "Number of recent executions to show (default: 5)"
269
+ }
270
+ },
271
+ "required": []
272
+ }
273
+ )
274
+ ]
275
+
276
+
277
+ @app.call_tool()
278
+ async def call_tool(name: str, arguments: Any) -> list[TextContent]:
279
+ """Handle tool execution."""
280
+
281
+ try:
282
+ if name == "build":
283
+ result = await build()
284
+
285
+ elif name == "connect":
286
+ device_serial = arguments.get("device_serial")
287
+ result = await connect(device_serial=device_serial)
288
+
289
+ elif name == "configure":
290
+ result = await configure(
291
+ quash_api_key=arguments.get("quash_api_key"),
292
+ model=arguments.get("model"),
293
+ temperature=arguments.get("temperature"),
294
+ max_steps=arguments.get("max_steps"),
295
+ vision=arguments.get("vision"),
296
+ reasoning=arguments.get("reasoning"),
297
+ reflection=arguments.get("reflection"),
298
+ debug=arguments.get("debug")
299
+ )
300
+
301
+ elif name == "execute":
302
+ task = arguments.get("task")
303
+ if not task:
304
+ return [TextContent(
305
+ type="text",
306
+ text="❌ Error: 'task' parameter is required"
307
+ )]
308
+
309
+ # Collect progress messages
310
+ progress_messages = []
311
+
312
+ def progress_callback(message: str):
313
+ progress_messages.append(message)
314
+
315
+ result = await execute(task=task, progress_callback=progress_callback)
316
+
317
+ # Combine progress messages with result
318
+ if progress_messages:
319
+ result["execution_log"] = "\n".join(progress_messages)
320
+
321
+ elif name == "runsuite":
322
+ suite_name = arguments.get("suite_name")
323
+ tasks = arguments.get("tasks")
324
+
325
+ if not suite_name:
326
+ return [TextContent(
327
+ type="text",
328
+ text="❌ Error: 'suite_name' parameter is required"
329
+ )]
330
+
331
+ if not tasks or not isinstance(tasks, list) or len(tasks) == 0:
332
+ return [TextContent(
333
+ type="text",
334
+ text="❌ Error: 'tasks' must be a non-empty array"
335
+ )]
336
+
337
+ # Collect progress messages
338
+ progress_messages = []
339
+
340
+ def progress_callback(message: str):
341
+ progress_messages.append(message)
342
+
343
+ result = await runsuite(
344
+ suite_name=suite_name,
345
+ tasks=tasks,
346
+ progress_callback=progress_callback
347
+ )
348
+
349
+ # Combine progress messages with result
350
+ if progress_messages:
351
+ result["execution_log"] = "\n".join(progress_messages)
352
+
353
+ elif name == "usage":
354
+ result = await usage(
355
+ api_key=arguments.get("api_key"),
356
+ show_recent=arguments.get("show_recent", 5)
357
+ )
358
+
359
+ else:
360
+ return [TextContent(
361
+ type="text",
362
+ text=f"❌ Unknown tool: {name}"
363
+ )]
364
+
365
+ # Format result as text
366
+ import json
367
+ result_text = json.dumps(result, indent=2)
368
+
369
+ return [TextContent(
370
+ type="text",
371
+ text=result_text
372
+ )]
373
+
374
+ except Exception as e:
375
+ logger.error(f"Error executing tool {name}: {e}", exc_info=True)
376
+ return [TextContent(
377
+ type="text",
378
+ text=f"❌ Error executing {name}: {str(e)}"
379
+ )]
380
+
381
+
382
+ async def async_main():
383
+ """Run the MCP server (async)."""
384
+ async with stdio_server() as (read_stream, write_stream):
385
+ logger.info("🚀 Quash MCP Server started")
386
+ await app.run(
387
+ read_stream,
388
+ write_stream,
389
+ app.create_initialization_options()
390
+ )
391
+
392
+
393
+ def main():
394
+ """Entry point for the quash-mcp command."""
395
+ asyncio.run(async_main())
396
+
397
+
398
+ if __name__ == "__main__":
399
+ main()