chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.16
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = mcp-server-v{new_version}
|
||||
message = Bump cua-mcp-server to v{new_version}
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
@@ -0,0 +1,259 @@
|
||||
# MCP Server Concurrent Session Management
|
||||
|
||||
This document describes the improvements made to the MCP Server to address concurrent session management and resource lifecycle issues.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The original MCP server implementation had several critical issues:
|
||||
|
||||
1. **Global Computer Instance**: Used a single `global_computer` variable shared across all clients
|
||||
2. **No Resource Isolation**: Multiple clients would interfere with each other
|
||||
3. **Sequential Task Processing**: Multi-task operations were always sequential
|
||||
4. **No Graceful Shutdown**: Server couldn't properly cleanup resources on shutdown
|
||||
5. **Hidden Event Loop**: `server.run()` hid the event loop, preventing proper lifecycle management
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### 1. Session Manager (`session_manager.py`)
|
||||
|
||||
The `SessionManager` class provides:
|
||||
|
||||
- **Per-session computer instances**: Each client gets isolated computer resources
|
||||
- **Computer instance pooling**: Efficient reuse of computer instances with lifecycle management
|
||||
- **Task registration**: Track active tasks per session for graceful cleanup
|
||||
- **Automatic cleanup**: Background task cleans up idle sessions
|
||||
- **Resource limits**: Configurable maximum concurrent sessions
|
||||
|
||||
#### Key Components:
|
||||
|
||||
```python
|
||||
class SessionManager:
|
||||
def __init__(self, max_concurrent_sessions: int = 10):
|
||||
self._sessions: Dict[str, SessionInfo] = {}
|
||||
self._computer_pool = ComputerPool()
|
||||
# ... lifecycle management
|
||||
```
|
||||
|
||||
#### Session Lifecycle:
|
||||
|
||||
1. **Creation**: New session created when client first connects
|
||||
2. **Task Registration**: Each task is registered with the session
|
||||
3. **Activity Tracking**: Last activity time updated on each operation
|
||||
4. **Cleanup**: Sessions cleaned up when idle or on shutdown
|
||||
|
||||
### 2. Computer Pool (`ComputerPool`)
|
||||
|
||||
Manages computer instances efficiently:
|
||||
|
||||
- **Pool Size Limits**: Maximum number of concurrent computer instances
|
||||
- **Instance Reuse**: Available instances reused across sessions
|
||||
- **Lifecycle Management**: Proper startup/shutdown of computer instances
|
||||
- **Resource Cleanup**: All instances properly closed on shutdown
|
||||
|
||||
### 3. Enhanced Server Tools
|
||||
|
||||
All server tools now support:
|
||||
|
||||
- **Session ID Parameter**: Optional `session_id` for multi-client support
|
||||
- **Resource Isolation**: Each session gets its own computer instance
|
||||
- **Task Tracking**: Proper registration/unregistration of tasks
|
||||
- **Error Handling**: Graceful error handling with session cleanup
|
||||
|
||||
#### Updated Tool Signatures:
|
||||
|
||||
```python
|
||||
async def screenshot_cua(ctx: Context, session_id: Optional[str] = None) -> Any:
|
||||
async def run_cua_task(ctx: Context, task: str, session_id: Optional[str] = None) -> Any:
|
||||
async def run_multi_cua_tasks(ctx: Context, tasks: List[str], session_id: Optional[str] = None, concurrent: bool = False) -> Any:
|
||||
```
|
||||
|
||||
### 4. Concurrent Task Execution
|
||||
|
||||
The `run_multi_cua_tasks` tool now supports:
|
||||
|
||||
- **Sequential Mode** (default): Tasks run one after another
|
||||
- **Concurrent Mode**: Tasks run in parallel using `asyncio.gather()`
|
||||
- **Progress Tracking**: Proper progress reporting for both modes
|
||||
- **Error Handling**: Individual task failures don't stop other tasks
|
||||
|
||||
### 5. Graceful Shutdown
|
||||
|
||||
The server now provides:
|
||||
|
||||
- **Signal Handlers**: Proper handling of SIGINT and SIGTERM
|
||||
- **Session Cleanup**: All active sessions properly cleaned up
|
||||
- **Resource Release**: Computer instances returned to pool and closed
|
||||
- **Async Lifecycle**: Event loop properly exposed for cleanup
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage (Backward Compatible)
|
||||
|
||||
```python
|
||||
# These calls work exactly as before
|
||||
await screenshot_cua(ctx)
|
||||
await run_cua_task(ctx, "Open browser")
|
||||
await run_multi_cua_tasks(ctx, ["Task 1", "Task 2"])
|
||||
```
|
||||
|
||||
### Multi-Client Usage
|
||||
|
||||
```python
|
||||
# Client 1
|
||||
session_id_1 = "client-1-session"
|
||||
await screenshot_cua(ctx, session_id_1)
|
||||
await run_cua_task(ctx, "Open browser", session_id_1)
|
||||
|
||||
# Client 2 (completely isolated)
|
||||
session_id_2 = "client-2-session"
|
||||
await screenshot_cua(ctx, session_id_2)
|
||||
await run_cua_task(ctx, "Open editor", session_id_2)
|
||||
```
|
||||
|
||||
### Concurrent Task Execution
|
||||
|
||||
```python
|
||||
# Run tasks concurrently instead of sequentially
|
||||
tasks = ["Open browser", "Open editor", "Open terminal"]
|
||||
results = await run_multi_cua_tasks(ctx, tasks, concurrent=True)
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
```python
|
||||
# Get session statistics
|
||||
stats = await get_session_stats(ctx)
|
||||
print(f"Active sessions: {stats['total_sessions']}")
|
||||
|
||||
# Cleanup specific session
|
||||
await cleanup_session(ctx, "session-to-cleanup")
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `CUA_MODEL_NAME`: Model to use (default: `anthropic/claude-sonnet-4-5-20250929`)
|
||||
- `CUA_MAX_IMAGES`: Maximum images to keep (default: `3`)
|
||||
|
||||
### Session Manager Configuration
|
||||
|
||||
```python
|
||||
# In session_manager.py
|
||||
class SessionManager:
|
||||
def __init__(self, max_concurrent_sessions: int = 10):
|
||||
# Configurable maximum concurrent sessions
|
||||
|
||||
class ComputerPool:
|
||||
def __init__(self, max_size: int = 5, idle_timeout: float = 300.0):
|
||||
# Configurable pool size and idle timeout
|
||||
```
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
### Before (Issues):
|
||||
|
||||
- ❌ Single global computer instance
|
||||
- ❌ Client interference and resource conflicts
|
||||
- ❌ Sequential task processing only
|
||||
- ❌ No graceful shutdown
|
||||
- ❌ 30s timeout issues with long-running tasks
|
||||
|
||||
### After (Benefits):
|
||||
|
||||
- ✅ Per-session computer instances with proper isolation
|
||||
- ✅ Computer instance pooling for efficient resource usage
|
||||
- ✅ Concurrent task execution support
|
||||
- ✅ Graceful shutdown with proper cleanup
|
||||
- ✅ Streaming updates prevent timeout issues
|
||||
- ✅ Configurable resource limits
|
||||
- ✅ Automatic session cleanup
|
||||
|
||||
## Testing
|
||||
|
||||
Comprehensive test coverage includes:
|
||||
|
||||
- Session creation and reuse
|
||||
- Concurrent session isolation
|
||||
- Task registration and cleanup
|
||||
- Error handling with session management
|
||||
- Concurrent vs sequential task execution
|
||||
- Session statistics and cleanup
|
||||
|
||||
Run tests with:
|
||||
|
||||
```bash
|
||||
pytest tests/test_mcp_server_session_management.py -v
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Existing Clients
|
||||
|
||||
No changes required! The new implementation is fully backward compatible:
|
||||
|
||||
```python
|
||||
# This still works exactly as before
|
||||
await run_cua_task(ctx, "My task")
|
||||
```
|
||||
|
||||
### For New Multi-Client Applications
|
||||
|
||||
Use session IDs for proper isolation:
|
||||
|
||||
```python
|
||||
# Create a unique session ID for each client
|
||||
session_id = str(uuid.uuid4())
|
||||
await run_cua_task(ctx, "My task", session_id)
|
||||
```
|
||||
|
||||
### For Concurrent Task Execution
|
||||
|
||||
Enable concurrent mode for better performance:
|
||||
|
||||
```python
|
||||
tasks = ["Task 1", "Task 2", "Task 3"]
|
||||
results = await run_multi_cua_tasks(ctx, tasks, concurrent=True)
|
||||
```
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### Session Statistics
|
||||
|
||||
```python
|
||||
stats = await get_session_stats(ctx)
|
||||
print(f"Total sessions: {stats['total_sessions']}")
|
||||
print(f"Max concurrent: {stats['max_concurrent']}")
|
||||
for session_id, session_info in stats['sessions'].items():
|
||||
print(f"Session {session_id}: {session_info['active_tasks']} active tasks")
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
The server provides detailed logging for:
|
||||
|
||||
- Session creation and cleanup
|
||||
- Task registration and completion
|
||||
- Resource pool usage
|
||||
- Error conditions and recovery
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
The server properly handles shutdown signals:
|
||||
|
||||
```bash
|
||||
# Send SIGTERM for graceful shutdown
|
||||
kill -TERM <server_pid>
|
||||
|
||||
# Or use Ctrl+C (SIGINT)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future improvements:
|
||||
|
||||
1. **Session Persistence**: Save/restore session state across restarts
|
||||
2. **Load Balancing**: Distribute sessions across multiple server instances
|
||||
3. **Resource Monitoring**: Real-time monitoring of resource usage
|
||||
4. **Auto-scaling**: Dynamic adjustment of pool size based on demand
|
||||
5. **Session Timeouts**: Configurable timeouts for different session types
|
||||
@@ -0,0 +1,5 @@
|
||||
# Cua MCP Server
|
||||
|
||||
MCP server for Computer-Use Agent (Cua), enabling Cua to run through MCP clients like Claude Desktop and Cursor.
|
||||
|
||||
**[Documentation](https://cua.ai/docs/cua/reference/mcp-server)** - Installation, guides, and configuration.
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script for Cua Desktop Extension (.mcpb file)
|
||||
|
||||
This script:
|
||||
1. Creates a temporary build directory
|
||||
2. Copies necessary files from mcp_server/ to the build directory
|
||||
3. Copies manifest and other static files
|
||||
4. Creates a .mcpb (zip) file
|
||||
5. Cleans up the temporary directory
|
||||
|
||||
Usage:
|
||||
python build-extension.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
"""Build the desktop extension."""
|
||||
# Get the script directory (libs/python/mcp-server)
|
||||
script_dir = Path(__file__).parent
|
||||
repo_root = script_dir.parent.parent.parent
|
||||
|
||||
# Define paths
|
||||
output_dir = script_dir / "desktop-extension"
|
||||
output_file = output_dir / "cua-extension.mcpb"
|
||||
|
||||
# Source directories
|
||||
mcp_server_dir = script_dir / "mcp_server"
|
||||
|
||||
# Required files to copy
|
||||
files_to_copy = {
|
||||
"manifest.json": output_dir / "manifest.json",
|
||||
"desktop_extension.png": output_dir / "desktop_extension.png",
|
||||
"requirements.txt": output_dir / "requirements.txt",
|
||||
"run_server.sh": output_dir / "run_server.sh",
|
||||
"setup.py": output_dir / "setup.py",
|
||||
}
|
||||
|
||||
# MCP server files to copy
|
||||
mcp_server_files = [
|
||||
"server.py",
|
||||
"session_manager.py",
|
||||
]
|
||||
|
||||
print("Building Cua Desktop Extension...")
|
||||
print(f" Output: {output_file}")
|
||||
|
||||
# Create temporary build directory
|
||||
with tempfile.TemporaryDirectory(prefix="cua-extension-build-") as build_dir:
|
||||
build_path = Path(build_dir)
|
||||
|
||||
# Copy MCP server files
|
||||
print(" Copying MCP server files...")
|
||||
for filename in mcp_server_files:
|
||||
src = mcp_server_dir / filename
|
||||
dst = build_path / filename
|
||||
if src.exists():
|
||||
shutil.copy2(src, dst)
|
||||
print(f" ✓ {filename}")
|
||||
else:
|
||||
print(f" ✗ {filename} (not found)")
|
||||
sys.exit(1)
|
||||
|
||||
# Copy static files from desktop-extension directory
|
||||
print(" Copying static files...")
|
||||
for src_name, src_path in files_to_copy.items():
|
||||
if src_path.exists():
|
||||
dst = build_path / src_name
|
||||
# Special handling for shell script - ensure executable
|
||||
shutil.copy2(src_path, dst)
|
||||
if src_name.endswith(".sh"):
|
||||
os.chmod(dst, 0o755)
|
||||
print(f" ✓ {src_name}")
|
||||
else:
|
||||
print(f" ✗ {src_name} (not found)")
|
||||
sys.exit(1)
|
||||
|
||||
# Validate manifest.json exists
|
||||
manifest_path = build_path / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
print(" ✗ manifest.json not found in build directory")
|
||||
sys.exit(1)
|
||||
|
||||
# Create the .mcpb file (zip archive)
|
||||
print(" Creating .mcpb archive...")
|
||||
with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Add all files from build directory to the zip
|
||||
for root, dirs, files in os.walk(build_path):
|
||||
# Skip __pycache__ and other unwanted directories
|
||||
dirs[:] = [d for d in dirs if d not in ["__pycache__", ".git"]]
|
||||
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
# Use relative path from build directory as archive name
|
||||
arcname = file_path.relative_to(build_path)
|
||||
zipf.write(file_path, arcname)
|
||||
print(f" ✓ Added {arcname}")
|
||||
|
||||
print(f"✓ Build complete: {output_file}")
|
||||
print(f" Archive size: {output_file.stat().st_size / 1024:.1f} KB")
|
||||
|
||||
# Set custom file icon based on platform
|
||||
icon_file = output_dir / "desktop_extension.png"
|
||||
if sys.platform == "darwin":
|
||||
_set_icon_macos(output_file, icon_file)
|
||||
elif sys.platform == "win32":
|
||||
_set_icon_windows(output_file, icon_file)
|
||||
elif sys.platform.startswith("linux"):
|
||||
_set_icon_linux(output_file, icon_file)
|
||||
|
||||
|
||||
def _set_icon_macos(output_file: Path, icon_file: Path):
|
||||
"""Set custom file icon on macOS."""
|
||||
try:
|
||||
# Check if fileicon is installed
|
||||
result = subprocess.run(["which", "fileicon"], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
# Use the logo as the file icon
|
||||
if icon_file.exists():
|
||||
print(" Setting custom file icon (macOS)...")
|
||||
subprocess.run(
|
||||
["fileicon", "set", str(output_file), str(icon_file)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
print(" ✓ File icon set")
|
||||
else:
|
||||
print(f" ⚠ Icon file not found: {icon_file}")
|
||||
else:
|
||||
print(" ⚠ fileicon not installed (optional - for custom file icon)")
|
||||
print(" Install with: brew install fileicon")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not set file icon: {e}")
|
||||
|
||||
|
||||
def _set_icon_windows(output_file: Path, icon_file: Path):
|
||||
"""Set custom file icon on Windows."""
|
||||
try:
|
||||
# Windows uses a desktop.ini approach, which is complex
|
||||
# For simplicity, we'll skip this for now
|
||||
print(" ⚠ Custom file icons not supported on Windows yet")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not set file icon: {e}")
|
||||
|
||||
|
||||
def _set_icon_linux(output_file: Path, icon_file: Path):
|
||||
"""Set custom file icon on Linux."""
|
||||
try:
|
||||
# Linux uses .desktop files and thumbnail generation
|
||||
# This is complex and depends on the desktop environment
|
||||
print(" ⚠ Custom file icons not supported on Linux yet")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not set file icon: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
# Cua Desktop Extension
|
||||
|
||||
This directory contains the build artifacts for the Cua Desktop Extension (`.mcpb` file).
|
||||
|
||||
## Building the Extension
|
||||
|
||||
To build the extension, run the build script from the parent directory:
|
||||
|
||||
```bash
|
||||
cd /path/to/libs/python/mcp-server
|
||||
python3 build-extension.py
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Copy the MCP server code from `mcp_server/` directory
|
||||
2. Copy static files (manifest, icons, scripts)
|
||||
3. Create a `.mcpb` zip archive in `desktop-extension/cua-extension.mcpb`
|
||||
4. Set custom file icon (platform-specific):
|
||||
- macOS: Uses `fileicon` (install with `brew install fileicon`)
|
||||
- Windows/Linux: Not yet supported
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
desktop-extension/
|
||||
├── README.md # This file
|
||||
├── manifest.json # Extension manifest
|
||||
├── desktop_extension.png # Extension icon
|
||||
├── requirements.txt # Python dependencies
|
||||
├── run_server.sh # Server launcher script
|
||||
├── setup.py # Dependency installer
|
||||
└── cua-extension.mcpb # Generated extension archive
|
||||
```
|
||||
|
||||
## Source Files
|
||||
|
||||
The actual MCP server code lives in `../mcp_server/`:
|
||||
|
||||
- `server.py` - Main MCP server implementation
|
||||
- `session_manager.py` - Session management and resource pooling
|
||||
|
||||
## Static Files
|
||||
|
||||
These files are maintained in this directory:
|
||||
|
||||
- `manifest.json` - Extension metadata and configuration
|
||||
- `desktop_extension.png` - Extension icon
|
||||
- `requirements.txt` - Python package dependencies
|
||||
- `run_server.sh` - Shell script to launch the server
|
||||
- `setup.py` - Python script to install dependencies
|
||||
|
||||
## Generated Files
|
||||
|
||||
The `.mcpb` file is generated by the build script and is gitignored.
|
||||
|
||||
## Installing the Extension
|
||||
|
||||
Once built, you can install the extension in Claude Desktop by:
|
||||
|
||||
1. Opening Claude Desktop Settings
|
||||
2. Going to the Extensions section
|
||||
3. Dropping the `cua-extension.mcpb` file into the window
|
||||
4. Following the installation prompts
|
||||
|
||||
For more information, see the [Anthropic Desktop Extensions documentation](https://www.anthropic.com/engineering/desktop-extensions).
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"manifest_version": "0.2",
|
||||
"name": "cua-mcp-server",
|
||||
"display_name": "Cua Computer-Use Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "Computer-Use Agent (Cua) MCP server for desktop automation and interaction",
|
||||
"long_description": "The Cua Computer-Use Agent extension provides powerful desktop automation capabilities through Claude Desktop. It can take screenshots, interact with applications, and execute complex computer tasks using AI agents. Perfect for automating repetitive desktop workflows, testing applications, and performing computer-based tasks through natural language instructions.",
|
||||
"author": {
|
||||
"name": "Cua",
|
||||
"email": "hello@trycua.com",
|
||||
"url": "https://trycua.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/trycua/cua"
|
||||
},
|
||||
"homepage": "https://trycua.com",
|
||||
"documentation": "https://docs.trycua.com",
|
||||
"support": "https://github.com/trycua/cua/issues",
|
||||
"icon": "desktop_extension.png",
|
||||
"server": {
|
||||
"type": "python",
|
||||
"entry_point": "server.py",
|
||||
"mcp_config": {
|
||||
"command": "${__dirname}/run_server.sh",
|
||||
"args": ["${__dirname}/server.py"],
|
||||
"env": {
|
||||
"PYTHONPATH": "${__dirname}",
|
||||
"API_KEY": "${user_config.api_key}",
|
||||
"CUA_MODEL_NAME": "${user_config.model_name}",
|
||||
"CUA_MAX_IMAGES": "${user_config.max_images}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"name": "screenshot_cua",
|
||||
"description": "Take a screenshot of the current desktop screen and return the image"
|
||||
},
|
||||
{
|
||||
"name": "run_cua_task",
|
||||
"description": "Run a Computer-Use Agent task on the desktop and return the result with screenshot"
|
||||
},
|
||||
{
|
||||
"name": "run_multi_cua_tasks",
|
||||
"description": "Run multiple Computer-Use Agent tasks sequentially or concurrently"
|
||||
},
|
||||
{
|
||||
"name": "get_session_stats",
|
||||
"description": "Get statistics about active sessions and resource usage"
|
||||
},
|
||||
{
|
||||
"name": "cleanup_session",
|
||||
"description": "Cleanup a specific session and release its resources"
|
||||
}
|
||||
],
|
||||
"keywords": ["automation", "computer-use", "desktop", "ai-agent", "productivity"],
|
||||
"license": "MIT",
|
||||
"user_config": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "Your API key for the AI model (Anthropic, OpenAI, etc.)",
|
||||
"sensitive": true,
|
||||
"required": true
|
||||
},
|
||||
"model_name": {
|
||||
"type": "string",
|
||||
"title": "Model Name",
|
||||
"description": "The AI model to use for computer tasks (e.g., anthropic/claude-sonnet-4-20250514, openai/gpt-4o)",
|
||||
"default": "anthropic/claude-sonnet-4-20250514",
|
||||
"required": false
|
||||
},
|
||||
"max_images": {
|
||||
"type": "number",
|
||||
"title": "Maximum Images",
|
||||
"description": "Maximum number of recent images to keep in context (default: 3)",
|
||||
"default": 3,
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Core MCP dependencies
|
||||
mcp>=1.6.0,<2.0.0
|
||||
|
||||
# Cua Agent dependencies
|
||||
cua-agent[all]>=0.4.0,<0.5.0
|
||||
cua-computer>=0.4.0,<0.5.0
|
||||
|
||||
# Additional dependencies for async operations
|
||||
anyio>=4.0.0
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Wrapper script to ensure we use the correct Python with dependencies
|
||||
|
||||
# Try different Python paths in order of preference
|
||||
PYTHON_PATHS=(
|
||||
"/Users/yellcw/Documents/GitHub/cua/.venv/bin/python"
|
||||
"/opt/homebrew/bin/python3"
|
||||
"/usr/local/bin/python3"
|
||||
"python3"
|
||||
)
|
||||
|
||||
# Find the first Python that has the required packages
|
||||
for python_path in "${PYTHON_PATHS[@]}"; do
|
||||
if command -v "$python_path" >/dev/null 2>&1; then
|
||||
# Check if it has the required packages
|
||||
if "$python_path" -c "import mcp, anyio" >/dev/null 2>&1; then
|
||||
echo "Using Python: $python_path" >&2
|
||||
exec "$python_path" "$@"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If no Python with packages found, try to install them
|
||||
echo "No Python with required packages found. Attempting to install..." >&2
|
||||
for python_path in "${PYTHON_PATHS[@]}"; do
|
||||
if command -v "$python_path" >/dev/null 2>&1; then
|
||||
echo "Installing packages with: $python_path" >&2
|
||||
if "$python_path" -m pip install mcp anyio cua-agent[all] cua-computer >/dev/null 2>&1; then
|
||||
echo "Packages installed successfully with: $python_path" >&2
|
||||
exec "$python_path" "$@"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Failed to find or install Python with required packages" >&2
|
||||
exit 1
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup script for Cua Desktop Extension
|
||||
Installs required dependencies if not available
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def check_and_install_package(package_name, import_name=None):
|
||||
"""Check if a package is available, install if not."""
|
||||
if import_name is None:
|
||||
import_name = package_name
|
||||
|
||||
try:
|
||||
__import__(import_name)
|
||||
print(f"✓ {package_name} is available")
|
||||
return True
|
||||
except ImportError:
|
||||
print(f"⚠ {package_name} not found, installing...")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", package_name])
|
||||
print(f"✓ {package_name} installed successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"✗ Failed to install {package_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Install required packages."""
|
||||
print("Setting up Cua Desktop Extension dependencies...")
|
||||
|
||||
# Required packages
|
||||
packages = [
|
||||
("mcp", "mcp"),
|
||||
("anyio", "anyio"),
|
||||
("cua-agent[all]", "agent"),
|
||||
("cua-computer", "computer"),
|
||||
]
|
||||
|
||||
all_installed = True
|
||||
for package, import_name in packages:
|
||||
if not check_and_install_package(package, import_name):
|
||||
all_installed = False
|
||||
|
||||
if all_installed:
|
||||
print("✓ All dependencies are ready!")
|
||||
return 0
|
||||
else:
|
||||
print("✗ Some dependencies failed to install")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
"""MCP Server for Computer-Use Agent (Cua)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add detailed debugging at import time
|
||||
with open("/tmp/mcp_server_debug.log", "w") as f:
|
||||
f.write(f"Python executable: {sys.executable}\n")
|
||||
f.write(f"Python version: {sys.version}\n")
|
||||
f.write(f"Working directory: {os.getcwd()}\n")
|
||||
f.write(f"Python path:\n{chr(10).join(sys.path)}\n")
|
||||
f.write("Environment variables:\n")
|
||||
for key, value in os.environ.items():
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
from .server import main, server
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["server", "main"]
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
"""Entry point for the MCP server module."""
|
||||
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,435 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import anyio
|
||||
|
||||
# Configure logging to output to stderr for debug visibility
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, # Changed to DEBUG
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("mcp-server")
|
||||
|
||||
# More visible startup message
|
||||
logger.debug("MCP Server module loading...")
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
# Use the canonical Image type
|
||||
from mcp.server.fastmcp.utilities.types import Image
|
||||
|
||||
logger.debug("Successfully imported FastMCP")
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import FastMCP: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from computer import Computer
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
logger.debug("Successfully imported Computer and Agent modules")
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import Computer/Agent modules: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from .session_manager import (
|
||||
get_session_manager,
|
||||
initialize_session_manager,
|
||||
shutdown_session_manager,
|
||||
)
|
||||
|
||||
logger.debug("Successfully imported session manager")
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import session manager: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_env_bool(key: str, default: bool = False) -> bool:
|
||||
"""Get boolean value from environment variable."""
|
||||
return os.getenv(key, str(default)).lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
async def _maybe_call_ctx_method(ctx: Context, method_name: str, *args, **kwargs) -> None:
|
||||
"""Call a context helper if it exists, awaiting the result when necessary."""
|
||||
method = getattr(ctx, method_name, None)
|
||||
if not callable(method):
|
||||
return
|
||||
result = method(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
def _normalise_message_content(content: Union[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||
"""Normalise message content to a list of structured parts."""
|
||||
if isinstance(content, list):
|
||||
return content
|
||||
if content is None:
|
||||
return []
|
||||
return [{"type": "output_text", "text": str(content)}]
|
||||
|
||||
|
||||
def _extract_text_from_content(content: Union[str, List[Dict[str, Any]]]) -> str:
|
||||
"""Extract textual content for inclusion in the aggregated result string."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
texts: List[str] = []
|
||||
for part in content or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") in {"output_text", "text"} and part.get("text"):
|
||||
texts.append(str(part["text"]))
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def _serialise_tool_content(content: Any) -> str:
|
||||
"""Convert tool outputs into a string for aggregation."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
texts: List[str] = []
|
||||
for part in content:
|
||||
if (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") in {"output_text", "text"}
|
||||
and part.get("text")
|
||||
):
|
||||
texts.append(str(part["text"]))
|
||||
if texts:
|
||||
return "\n".join(texts)
|
||||
if content is None:
|
||||
return ""
|
||||
return str(content)
|
||||
|
||||
|
||||
def serve() -> FastMCP:
|
||||
"""Create and configure the MCP server."""
|
||||
# NOTE: Do not pass model_config here; FastMCP 2.12.x doesn't support it.
|
||||
server = FastMCP(name="cua-agent")
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
async def screenshot_cua(ctx: Context, session_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Take a screenshot of the current MacOS VM screen and return the image.
|
||||
|
||||
Args:
|
||||
session_id: Optional session ID for multi-client support. If not provided, a new session will be created.
|
||||
"""
|
||||
session_manager = get_session_manager()
|
||||
|
||||
async with session_manager.get_session(session_id) as session:
|
||||
screenshot = await session.computer.interface.screenshot()
|
||||
# Returning Image object is fine when structured_output=False
|
||||
return Image(format="png", data=screenshot)
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
async def run_cua_task(ctx: Context, task: str, session_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Run a Computer-Use Agent (Cua) task in a MacOS VM and return (combined text, final screenshot).
|
||||
|
||||
Args:
|
||||
task: The task description for the agent to execute
|
||||
session_id: Optional session ID for multi-client support. If not provided, a new session will be created.
|
||||
"""
|
||||
session_manager = get_session_manager()
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
logger.info(f"Starting Cua task: {task} (task_id: {task_id})")
|
||||
|
||||
async with session_manager.get_session(session_id) as session:
|
||||
# Register this task with the session
|
||||
await session_manager.register_task(session.session_id, task_id)
|
||||
|
||||
try:
|
||||
# Get model name
|
||||
model_name = os.getenv("CUA_MODEL_NAME", "anthropic/claude-sonnet-4-5-20250929")
|
||||
logger.info(f"Using model: {model_name}")
|
||||
|
||||
# Create agent with the new v0.4.x API
|
||||
agent = ComputerAgent(
|
||||
model=model_name,
|
||||
only_n_most_recent_images=int(os.getenv("CUA_MAX_IMAGES", "3")),
|
||||
verbosity=logging.INFO,
|
||||
tools=[session.computer],
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": task}]
|
||||
|
||||
# Collect all results
|
||||
aggregated_messages: List[str] = []
|
||||
async for result in agent.run(messages):
|
||||
logger.info("Agent processing step")
|
||||
ctx.info("Agent processing step")
|
||||
|
||||
outputs = result.get("output", [])
|
||||
for output in outputs:
|
||||
output_type = output.get("type")
|
||||
|
||||
if output_type == "message":
|
||||
logger.debug("Streaming assistant message: %s", output)
|
||||
content = _normalise_message_content(output.get("content"))
|
||||
aggregated_text = _extract_text_from_content(content)
|
||||
if aggregated_text:
|
||||
aggregated_messages.append(aggregated_text)
|
||||
await _maybe_call_ctx_method(
|
||||
ctx,
|
||||
"yield_message",
|
||||
role=output.get("role", "assistant"),
|
||||
content=content,
|
||||
)
|
||||
|
||||
elif output_type in {"tool_use", "computer_call", "function_call"}:
|
||||
logger.debug("Streaming tool call: %s", output)
|
||||
call_id = output.get("id") or output.get("call_id")
|
||||
tool_name = output.get("name") or output.get("action", {}).get(
|
||||
"type"
|
||||
)
|
||||
tool_input = (
|
||||
output.get("input")
|
||||
or output.get("arguments")
|
||||
or output.get("action")
|
||||
)
|
||||
if call_id:
|
||||
await _maybe_call_ctx_method(
|
||||
ctx,
|
||||
"yield_tool_call",
|
||||
name=tool_name,
|
||||
call_id=call_id,
|
||||
input=tool_input,
|
||||
)
|
||||
|
||||
elif output_type in {
|
||||
"tool_result",
|
||||
"computer_call_output",
|
||||
"function_call_output",
|
||||
}:
|
||||
logger.debug("Streaming tool output: %s", output)
|
||||
call_id = output.get("call_id") or output.get("id")
|
||||
content = output.get("content") or output.get("output")
|
||||
aggregated_text = _serialise_tool_content(content)
|
||||
if aggregated_text:
|
||||
aggregated_messages.append(aggregated_text)
|
||||
if call_id:
|
||||
await _maybe_call_ctx_method(
|
||||
ctx,
|
||||
"yield_tool_output",
|
||||
call_id=call_id,
|
||||
output=content,
|
||||
is_error=output.get("status") == "failed"
|
||||
or output.get("is_error", False),
|
||||
)
|
||||
|
||||
logger.info("Cua task completed successfully")
|
||||
ctx.info("Cua task completed successfully")
|
||||
|
||||
screenshot_image = Image(
|
||||
format="png",
|
||||
data=await session.computer.interface.screenshot(),
|
||||
)
|
||||
|
||||
return (
|
||||
"\n".join(aggregated_messages).strip()
|
||||
or "Task completed with no text output.",
|
||||
screenshot_image,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Unregister the task from the session
|
||||
await session_manager.unregister_task(session.session_id, task_id)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error running Cua task: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(error_msg)
|
||||
ctx.error(error_msg)
|
||||
|
||||
# Try to get a screenshot from the session if available
|
||||
try:
|
||||
if session_id:
|
||||
async with session_manager.get_session(session_id) as session:
|
||||
screenshot = await session.computer.interface.screenshot()
|
||||
return (
|
||||
f"Error during task execution: {str(e)}",
|
||||
Image(format="png", data=screenshot),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If we can't get a screenshot, return a placeholder
|
||||
return (
|
||||
f"Error during task execution: {str(e)}",
|
||||
Image(format="png", data=b""),
|
||||
)
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
async def run_multi_cua_tasks(
|
||||
ctx: Context, tasks: List[str], session_id: Optional[str] = None, concurrent: bool = False
|
||||
) -> Any:
|
||||
"""
|
||||
Run multiple Cua tasks and return a list of (combined text, screenshot).
|
||||
|
||||
Args:
|
||||
tasks: List of task descriptions to execute
|
||||
session_id: Optional session ID for multi-client support. If not provided, a new session will be created.
|
||||
concurrent: If True, run tasks concurrently. If False, run sequentially (default).
|
||||
"""
|
||||
total_tasks = len(tasks)
|
||||
if total_tasks == 0:
|
||||
ctx.report_progress(1.0)
|
||||
return []
|
||||
|
||||
session_manager = get_session_manager()
|
||||
|
||||
if concurrent and total_tasks > 1:
|
||||
# Run tasks concurrently
|
||||
logger.info(f"Running {total_tasks} tasks concurrently")
|
||||
ctx.info(f"Running {total_tasks} tasks concurrently")
|
||||
|
||||
# Create tasks with progress tracking
|
||||
async def run_task_with_progress(
|
||||
task_index: int, task: str
|
||||
) -> Tuple[int, Tuple[str, Image]]:
|
||||
ctx.report_progress(task_index / total_tasks)
|
||||
result = await run_cua_task(ctx, task, session_id)
|
||||
ctx.report_progress((task_index + 1) / total_tasks)
|
||||
return task_index, result
|
||||
|
||||
# Create all task coroutines
|
||||
task_coroutines = [run_task_with_progress(i, task) for i, task in enumerate(tasks)]
|
||||
|
||||
# Wait for all tasks to complete
|
||||
results_with_indices = await asyncio.gather(*task_coroutines, return_exceptions=True)
|
||||
|
||||
# Sort results by original task order and handle exceptions
|
||||
results: List[Tuple[str, Image]] = []
|
||||
for result in results_with_indices:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Task failed with exception: {result}")
|
||||
ctx.error(f"Task failed: {str(result)}")
|
||||
results.append((f"Task failed: {str(result)}", Image(format="png", data=b"")))
|
||||
else:
|
||||
_, task_result = result
|
||||
results.append(task_result)
|
||||
|
||||
return results
|
||||
else:
|
||||
# Run tasks sequentially (original behavior)
|
||||
logger.info(f"Running {total_tasks} tasks sequentially")
|
||||
ctx.info(f"Running {total_tasks} tasks sequentially")
|
||||
|
||||
results: List[Tuple[str, Image]] = []
|
||||
for i, task in enumerate(tasks):
|
||||
logger.info(f"Running task {i+1}/{total_tasks}: {task}")
|
||||
ctx.info(f"Running task {i+1}/{total_tasks}: {task}")
|
||||
|
||||
ctx.report_progress(i / total_tasks)
|
||||
task_result = await run_cua_task(ctx, task, session_id)
|
||||
results.append(task_result)
|
||||
ctx.report_progress((i + 1) / total_tasks)
|
||||
|
||||
return results
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
async def get_session_stats(ctx: Context) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about active sessions and resource usage.
|
||||
"""
|
||||
session_manager = get_session_manager()
|
||||
return session_manager.get_session_stats()
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
async def cleanup_session(ctx: Context, session_id: str) -> str:
|
||||
"""
|
||||
Cleanup a specific session and release its resources.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to cleanup
|
||||
"""
|
||||
session_manager = get_session_manager()
|
||||
await session_manager.cleanup_session(session_id)
|
||||
return f"Session {session_id} cleanup initiated"
|
||||
|
||||
return server
|
||||
|
||||
|
||||
server = serve()
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Run the MCP server with proper lifecycle management."""
|
||||
session_manager = None
|
||||
try:
|
||||
logger.debug("Starting MCP server...")
|
||||
|
||||
# Initialize session manager
|
||||
session_manager = await initialize_session_manager()
|
||||
logger.info("Session manager initialized")
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
def signal_handler(signum, frame):
|
||||
logger.info(f"Received signal {signum}, initiating graceful shutdown...")
|
||||
# Create a task to shutdown gracefully
|
||||
asyncio.create_task(graceful_shutdown())
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Start the server
|
||||
logger.info("Starting FastMCP server...")
|
||||
# Use run_stdio_async directly instead of server.run() to avoid nested event loops
|
||||
await server.run_stdio_async()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting server: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
# Ensure cleanup happens
|
||||
if session_manager:
|
||||
logger.info("Shutting down session manager...")
|
||||
await shutdown_session_manager()
|
||||
|
||||
|
||||
async def graceful_shutdown():
|
||||
"""Gracefully shutdown the server and all sessions."""
|
||||
logger.info("Initiating graceful shutdown...")
|
||||
try:
|
||||
await shutdown_session_manager()
|
||||
logger.info("Graceful shutdown completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graceful shutdown: {e}")
|
||||
finally:
|
||||
# Exit the process
|
||||
import os
|
||||
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the MCP server with proper async lifecycle management."""
|
||||
try:
|
||||
# Use anyio.run instead of asyncio.run to avoid nested event loop issues
|
||||
anyio.run(run_server)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server interrupted by user")
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting server: {e}")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Session Manager for MCP Server - Handles concurrent client sessions with proper resource isolation.
|
||||
|
||||
This module provides:
|
||||
- Per-session computer instance management
|
||||
- Resource pooling and lifecycle management
|
||||
- Graceful session cleanup
|
||||
- Concurrent task execution support
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import weakref
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger("mcp-server.session_manager")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionInfo:
|
||||
"""Information about an active session."""
|
||||
|
||||
session_id: str
|
||||
computer: Any # Computer instance
|
||||
created_at: float
|
||||
last_activity: float
|
||||
active_tasks: Set[str] = field(default_factory=set)
|
||||
is_shutting_down: bool = False
|
||||
|
||||
|
||||
class ComputerPool:
|
||||
"""Pool of computer instances for efficient resource management."""
|
||||
|
||||
def __init__(self, max_size: int = 5, idle_timeout: float = 300.0):
|
||||
self.max_size = max_size
|
||||
self.idle_timeout = idle_timeout
|
||||
self._available: List[Any] = []
|
||||
self._in_use: Set[Any] = set()
|
||||
self._creation_lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self) -> Any:
|
||||
"""Acquire a computer instance from the pool."""
|
||||
# Try to get an available instance
|
||||
if self._available:
|
||||
computer = self._available.pop()
|
||||
self._in_use.add(computer)
|
||||
logger.debug("Reusing computer instance from pool")
|
||||
return computer
|
||||
|
||||
# Check if we can create a new one
|
||||
async with self._creation_lock:
|
||||
if len(self._in_use) < self.max_size:
|
||||
logger.debug("Creating new computer instance")
|
||||
from computer import Computer
|
||||
|
||||
# Check if we should use host computer server
|
||||
use_host = os.getenv("CUA_USE_HOST_COMPUTER_SERVER", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
computer = Computer(verbosity=logging.INFO, use_host_computer_server=use_host)
|
||||
await computer.run()
|
||||
self._in_use.add(computer)
|
||||
return computer
|
||||
|
||||
# Wait for an instance to become available
|
||||
logger.debug("Waiting for computer instance to become available")
|
||||
while not self._available:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
computer = self._available.pop()
|
||||
self._in_use.add(computer)
|
||||
return computer
|
||||
|
||||
async def release(self, computer: Any) -> None:
|
||||
"""Release a computer instance back to the pool."""
|
||||
if computer in self._in_use:
|
||||
self._in_use.remove(computer)
|
||||
self._available.append(computer)
|
||||
logger.debug("Released computer instance back to pool")
|
||||
|
||||
async def cleanup_idle(self) -> None:
|
||||
"""Clean up idle computer instances."""
|
||||
current_time = time.time()
|
||||
idle_instances = []
|
||||
|
||||
for computer in self._available[:]:
|
||||
# Check if computer has been idle too long
|
||||
# Note: We'd need to track last use time per instance for this
|
||||
# For now, we'll keep instances in the pool
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Shutdown all computer instances in the pool."""
|
||||
logger.info("Shutting down computer pool")
|
||||
|
||||
# Close all available instances
|
||||
for computer in self._available:
|
||||
try:
|
||||
if hasattr(computer, "close"):
|
||||
await computer.close()
|
||||
elif hasattr(computer, "stop"):
|
||||
await computer.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing computer instance: {e}")
|
||||
|
||||
# Close all in-use instances
|
||||
for computer in self._in_use:
|
||||
try:
|
||||
if hasattr(computer, "close"):
|
||||
await computer.close()
|
||||
elif hasattr(computer, "stop"):
|
||||
await computer.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing computer instance: {e}")
|
||||
|
||||
self._available.clear()
|
||||
self._in_use.clear()
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages concurrent client sessions with proper resource isolation."""
|
||||
|
||||
def __init__(self, max_concurrent_sessions: int = 10):
|
||||
self.max_concurrent_sessions = max_concurrent_sessions
|
||||
self._sessions: Dict[str, SessionInfo] = {}
|
||||
self._computer_pool = ComputerPool()
|
||||
self._session_lock = asyncio.Lock()
|
||||
self._cleanup_task: Optional[asyncio.Task] = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the session manager and cleanup task."""
|
||||
logger.info("Starting session manager")
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the session manager and cleanup all resources."""
|
||||
logger.info("Stopping session manager")
|
||||
self._shutdown_event.set()
|
||||
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Force cleanup all sessions
|
||||
async with self._session_lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
|
||||
for session_id in session_ids:
|
||||
await self._force_cleanup_session(session_id)
|
||||
|
||||
await self._computer_pool.shutdown()
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session(self, session_id: Optional[str] = None) -> Any:
|
||||
"""Get or create a session with proper resource management."""
|
||||
if session_id is None:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# Check if session exists and is not shutting down
|
||||
async with self._session_lock:
|
||||
if session_id in self._sessions:
|
||||
session = self._sessions[session_id]
|
||||
if session.is_shutting_down:
|
||||
raise RuntimeError(f"Session {session_id} is shutting down")
|
||||
session.last_activity = time.time()
|
||||
computer = session.computer
|
||||
else:
|
||||
# Create new session
|
||||
if len(self._sessions) >= self.max_concurrent_sessions:
|
||||
raise RuntimeError(
|
||||
f"Maximum concurrent sessions ({self.max_concurrent_sessions}) reached"
|
||||
)
|
||||
|
||||
computer = await self._computer_pool.acquire()
|
||||
session = SessionInfo(
|
||||
session_id=session_id,
|
||||
computer=computer,
|
||||
created_at=time.time(),
|
||||
last_activity=time.time(),
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
logger.info(f"Created new session: {session_id}")
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
# Update last activity
|
||||
async with self._session_lock:
|
||||
if session_id in self._sessions:
|
||||
self._sessions[session_id].last_activity = time.time()
|
||||
|
||||
async def register_task(self, session_id: str, task_id: str) -> None:
|
||||
"""Register a task for a session."""
|
||||
async with self._session_lock:
|
||||
if session_id in self._sessions:
|
||||
self._sessions[session_id].active_tasks.add(task_id)
|
||||
logger.debug(f"Registered task {task_id} for session {session_id}")
|
||||
|
||||
async def unregister_task(self, session_id: str, task_id: str) -> None:
|
||||
"""Unregister a task from a session."""
|
||||
async with self._session_lock:
|
||||
if session_id in self._sessions:
|
||||
self._sessions[session_id].active_tasks.discard(task_id)
|
||||
logger.debug(f"Unregistered task {task_id} from session {session_id}")
|
||||
|
||||
async def cleanup_session(self, session_id: str) -> None:
|
||||
"""Cleanup a specific session."""
|
||||
async with self._session_lock:
|
||||
if session_id not in self._sessions:
|
||||
return
|
||||
|
||||
session = self._sessions[session_id]
|
||||
|
||||
# Check if session has active tasks
|
||||
if session.active_tasks:
|
||||
logger.info(f"Session {session_id} has active tasks, marking for shutdown")
|
||||
session.is_shutting_down = True
|
||||
return
|
||||
|
||||
# Actually cleanup the session
|
||||
await self._force_cleanup_session(session_id)
|
||||
|
||||
async def _force_cleanup_session(self, session_id: str) -> None:
|
||||
"""Force cleanup a session regardless of active tasks."""
|
||||
async with self._session_lock:
|
||||
if session_id not in self._sessions:
|
||||
return
|
||||
|
||||
session = self._sessions[session_id]
|
||||
logger.info(f"Cleaning up session: {session_id}")
|
||||
|
||||
# Release computer back to pool
|
||||
await self._computer_pool.release(session.computer)
|
||||
|
||||
# Remove session
|
||||
del self._sessions[session_id]
|
||||
|
||||
async def _cleanup_loop(self) -> None:
|
||||
"""Background task to cleanup idle sessions."""
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
await asyncio.sleep(60) # Run cleanup every minute
|
||||
|
||||
current_time = time.time()
|
||||
idle_timeout = 600.0 # 10 minutes
|
||||
|
||||
async with self._session_lock:
|
||||
idle_sessions = []
|
||||
for session_id, session in self._sessions.items():
|
||||
if not session.is_shutting_down and not session.active_tasks:
|
||||
if current_time - session.last_activity > idle_timeout:
|
||||
idle_sessions.append(session_id)
|
||||
|
||||
# Cleanup idle sessions
|
||||
for session_id in idle_sessions:
|
||||
await self._force_cleanup_session(session_id)
|
||||
logger.info(f"Cleaned up idle session: {session_id}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup loop: {e}")
|
||||
|
||||
def get_session_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about active sessions."""
|
||||
|
||||
async def _get_stats():
|
||||
async with self._session_lock:
|
||||
return {
|
||||
"total_sessions": len(self._sessions),
|
||||
"max_concurrent": self.max_concurrent_sessions,
|
||||
"sessions": {
|
||||
session_id: {
|
||||
"created_at": session.created_at,
|
||||
"last_activity": session.last_activity,
|
||||
"active_tasks": len(session.active_tasks),
|
||||
"is_shutting_down": session.is_shutting_down,
|
||||
}
|
||||
for session_id, session in self._sessions.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Run in current event loop or create new one
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
return asyncio.run_coroutine_threadsafe(_get_stats(), loop).result()
|
||||
except RuntimeError:
|
||||
# No event loop running, create a new one
|
||||
return asyncio.run(_get_stats())
|
||||
|
||||
|
||||
# Global session manager instance
|
||||
_session_manager: Optional[SessionManager] = None
|
||||
|
||||
|
||||
def get_session_manager() -> SessionManager:
|
||||
"""Get the global session manager instance."""
|
||||
global _session_manager
|
||||
if _session_manager is None:
|
||||
_session_manager = SessionManager()
|
||||
return _session_manager
|
||||
|
||||
|
||||
async def initialize_session_manager() -> None:
|
||||
"""Initialize the global session manager."""
|
||||
global _session_manager
|
||||
if _session_manager is None:
|
||||
_session_manager = SessionManager()
|
||||
await _session_manager.start()
|
||||
return _session_manager
|
||||
|
||||
|
||||
async def shutdown_session_manager() -> None:
|
||||
"""Shutdown the global session manager."""
|
||||
global _session_manager
|
||||
if _session_manager is not None:
|
||||
await _session_manager.stop()
|
||||
_session_manager = None
|
||||
Generated
+4465
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[project]
|
||||
name = "cua-mcp-server"
|
||||
description = "MCP Server for Computer-Use Agent (Cua)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
version = "0.1.16"
|
||||
authors = [
|
||||
{name = "TryCua", email = "gh@trycua.com"}
|
||||
]
|
||||
dependencies = [
|
||||
"mcp>=1.6.0,<2.0.0",
|
||||
"cua-agent[all]>=0.8.0",
|
||||
"cua-computer>=0.4.0,<0.5.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cua-mcp-server = "mcp_server.server:main"
|
||||
|
||||
[tool.pdm]
|
||||
distribution = true
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"black>=23.9.1",
|
||||
"ruff>=0.0.292",
|
||||
]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Create the ~/.cua directory if it doesn't exist
|
||||
mkdir -p "$HOME/.cua"
|
||||
|
||||
# Create start_mcp_server.sh script in ~/.cua directory
|
||||
cat > "$HOME/.cua/start_mcp_server.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Function to check if a directory is writable
|
||||
is_writable() {
|
||||
[ -w "$1" ]
|
||||
}
|
||||
|
||||
# Function to check if a command exists (silent)
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Find a writable directory for the virtual environment
|
||||
if is_writable "$HOME"; then
|
||||
VENV_DIR="$HOME/.cua-mcp-venv"
|
||||
elif is_writable "/tmp"; then
|
||||
VENV_DIR="/tmp/.cua-mcp-venv"
|
||||
else
|
||||
# Try to create a directory in the current working directory
|
||||
TEMP_DIR="$(pwd)/.cua-mcp-venv"
|
||||
if is_writable "$(pwd)"; then
|
||||
VENV_DIR="$TEMP_DIR"
|
||||
else
|
||||
echo "Error: Cannot find a writable directory for the virtual environment." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if Python is installed
|
||||
if ! command_exists python3; then
|
||||
echo "Error: Python 3 is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if pip is installed
|
||||
if ! command_exists pip3; then
|
||||
echo "Error: pip3 is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
# Redirect output to prevent JSON parsing errors in Claude
|
||||
python3 -m venv "$VENV_DIR" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# Always install/upgrade the latest version of cua-mcp-server
|
||||
pip install --upgrade "cua-mcp-server"
|
||||
|
||||
# Run the MCP server with isolation from development paths
|
||||
cd "$VENV_DIR" # Change to venv directory to avoid current directory in path
|
||||
|
||||
python3 -c "from mcp_server.server import main; main()"
|
||||
EOF
|
||||
|
||||
# Make the script executable
|
||||
chmod +x "$HOME/.cua/start_mcp_server.sh"
|
||||
|
||||
echo "MCP server startup script created at $HOME/.cua/start_mcp_server.sh"
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# --- Resolve repo root from this script's location ---
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
CUA_REPO_DIR="$( cd "$SCRIPT_DIR/../../../.." &> /dev/null && pwd )"
|
||||
|
||||
# --- Choose a Python interpreter (prefer repo-root venv) ---
|
||||
CANDIDATES=(
|
||||
"$CUA_REPO_DIR/.venv/bin/python"
|
||||
"$CUA_REPO_DIR/libs/.venv/bin/python"
|
||||
"$(command -v python3 || true)"
|
||||
"$(command -v python || true)"
|
||||
)
|
||||
|
||||
PYTHON_PATH=""
|
||||
for p in "${CANDIDATES[@]}"; do
|
||||
if [[ -n "$p" && -x "$p" ]]; then
|
||||
PYTHON_PATH="$p"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "${PYTHON_PATH}" ]]; then
|
||||
>&2 echo "[cua-mcp] ERROR: No suitable Python found. Tried:"
|
||||
for p in "${CANDIDATES[@]}"; do >&2 echo " - $p"; done
|
||||
>&2 echo "[cua-mcp] Tip: create venv: python3 -m venv $CUA_REPO_DIR/.venv && \"$CUA_REPO_DIR/.venv/bin/pip\" install -e \"$CUA_REPO_DIR/libs/python/mcp-server\""
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# --- Export PYTHONPATH so module imports work during dev ---
|
||||
export PYTHONPATH="$CUA_REPO_DIR/libs/python/mcp-server:$CUA_REPO_DIR/libs/python/agent:$CUA_REPO_DIR/libs/python/computer:$CUA_REPO_DIR/libs/python/core"
|
||||
|
||||
# --- Helpful startup log for Claude's mcp.log ---
|
||||
>&2 echo "[cua-mcp] using python: $PYTHON_PATH"
|
||||
>&2 echo "[cua-mcp] repo dir : $CUA_REPO_DIR"
|
||||
>&2 echo "[cua-mcp] PYTHONPATH : $PYTHONPATH"
|
||||
if [[ -n "${CUA_MODEL_NAME:-}" ]]; then
|
||||
>&2 echo "[cua-mcp] CUA_MODEL_NAME=$CUA_MODEL_NAME"
|
||||
fi
|
||||
|
||||
# --- Run the MCP server module ---
|
||||
exec "$PYTHON_PATH" -m mcp_server.server
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test script to run the MCP server and see all errors
|
||||
# This is for LOCAL DEVELOPMENT ONLY - helps debug issues before connecting from Claude
|
||||
|
||||
set -e # Exit on error, but we'll see the error
|
||||
|
||||
echo "=========================================="
|
||||
echo "CUA MCP Server - Development Test"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Get the repo root
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
CUA_REPO_DIR="$( cd "$SCRIPT_DIR/../../../.." &> /dev/null && pwd )"
|
||||
|
||||
echo "Repo directory: $CUA_REPO_DIR"
|
||||
|
||||
# Find Python
|
||||
if [ -f "$CUA_REPO_DIR/.venv/bin/python" ]; then
|
||||
PYTHON="$CUA_REPO_DIR/.venv/bin/python"
|
||||
elif [ -f "$CUA_REPO_DIR/libs/.venv/bin/python" ]; then
|
||||
PYTHON="$CUA_REPO_DIR/libs/.venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
echo "Using Python: $PYTHON"
|
||||
$PYTHON --version
|
||||
echo ""
|
||||
|
||||
# Set up environment
|
||||
export PYTHONPATH="$CUA_REPO_DIR/libs/python/mcp-server:$CUA_REPO_DIR/libs/python/agent:$CUA_REPO_DIR/libs/python/computer:$CUA_REPO_DIR/libs/python/core"
|
||||
export CUA_MODEL_NAME="${CUA_MODEL_NAME:-anthropic/claude-sonnet-4-5-20250929}"
|
||||
|
||||
echo "Environment:"
|
||||
echo " PYTHONPATH: $PYTHONPATH"
|
||||
echo " CUA_MODEL_NAME: $CUA_MODEL_NAME"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Starting MCP server..."
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Run the server WITHOUT redirecting stderr, so we can see all errors
|
||||
exec "$PYTHON" -m mcp_server.server
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Test script to verify MCP Server local desktop option works correctly.
|
||||
|
||||
This test verifies:
|
||||
1. Default behavior: Computer uses VM
|
||||
2. New behavior: Computer uses host when CUA_USE_HOST_COMPUTER_SERVER=true
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the mcp-server module to path
|
||||
mcp_server_path = Path(__file__).parent.parent / "libs" / "python" / "mcp-server"
|
||||
sys.path.insert(0, str(mcp_server_path.parent.parent.parent / "libs" / "python"))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_vm_mode():
|
||||
"""Test that the default mode uses VM (not host computer server)."""
|
||||
# Ensure environment variable is not set or is false
|
||||
os.environ.pop("CUA_USE_HOST_COMPUTER_SERVER", None)
|
||||
|
||||
from mcp_server.session_manager import ComputerPool
|
||||
|
||||
pool = ComputerPool(max_size=1)
|
||||
|
||||
try:
|
||||
computer = await pool.acquire()
|
||||
|
||||
# Verify the computer was initialized
|
||||
assert computer is not None
|
||||
|
||||
# Check that use_host_computer_server was set to False (default)
|
||||
# This should start a VM
|
||||
print("✓ Default mode: Computer initialized (VM mode expected)")
|
||||
|
||||
await pool.release(computer)
|
||||
|
||||
finally:
|
||||
await pool.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_desktop_mode():
|
||||
"""Test that setting CUA_USE_HOST_COMPUTER_SERVER=true uses host."""
|
||||
# Set environment variable to true
|
||||
os.environ["CUA_USE_HOST_COMPUTER_SERVER"] = "true"
|
||||
|
||||
# Need to reload module to pick up new env var
|
||||
import importlib
|
||||
|
||||
import mcp_server.session_manager
|
||||
from mcp_server.session_manager import ComputerPool
|
||||
|
||||
importlib.reload(mcp_server.session_manager)
|
||||
|
||||
pool = mcp_server.session_manager.ComputerPool(max_size=1)
|
||||
|
||||
try:
|
||||
computer = await pool.acquire()
|
||||
|
||||
# Verify the computer was initialized
|
||||
assert computer is not None
|
||||
|
||||
# Check that use_host_computer_server was set to True
|
||||
print("✓ Local mode: Computer initialized (host mode expected)")
|
||||
|
||||
await pool.release(computer)
|
||||
|
||||
finally:
|
||||
await pool.shutdown()
|
||||
# Clean up env var
|
||||
os.environ.pop("CUA_USE_HOST_COMPUTER_SERVER", None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_var_parsing():
|
||||
"""Test that various values of CUA_USE_HOST_COMPUTER_SERVER are parsed correctly."""
|
||||
test_cases = [
|
||||
("true", True),
|
||||
("True", True),
|
||||
("TRUE", True),
|
||||
("1", True),
|
||||
("yes", True),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("FALSE", False),
|
||||
("0", False),
|
||||
("no", False),
|
||||
("", False),
|
||||
("random", False),
|
||||
]
|
||||
|
||||
for value, expected in test_cases:
|
||||
os.environ["CUA_USE_HOST_COMPUTER_SERVER"] = value
|
||||
|
||||
# Check parsing logic
|
||||
use_host = os.getenv("CUA_USE_HOST_COMPUTER_SERVER", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
assert (
|
||||
use_host == expected
|
||||
), f"Failed for value '{value}': expected {expected}, got {use_host}"
|
||||
print(f"✓ Env var '{value}' correctly parsed as {expected}")
|
||||
|
||||
os.environ.pop("CUA_USE_HOST_COMPUTER_SERVER", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing MCP Server Local Desktop Option")
|
||||
print("=" * 60)
|
||||
|
||||
print("\n1. Testing environment variable parsing...")
|
||||
asyncio.run(test_env_var_parsing())
|
||||
|
||||
print("\n2. Testing default VM mode...")
|
||||
try:
|
||||
asyncio.run(test_default_vm_mode())
|
||||
except Exception as e:
|
||||
print(f"✗ Default VM mode test failed: {e}")
|
||||
print("Note: This may require lume/VM setup to fully test")
|
||||
|
||||
print("\n3. Testing local desktop mode...")
|
||||
try:
|
||||
asyncio.run(test_local_desktop_mode())
|
||||
except Exception as e:
|
||||
print(f"✗ Local desktop mode test failed: {e}")
|
||||
print("Note: This may require computer-server to be running locally")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Tests completed!")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Pytest configuration and shared fixtures for mcp-server package tests.
|
||||
|
||||
This file contains shared fixtures and configuration for all mcp-server tests.
|
||||
Following SRP: This file ONLY handles test setup/teardown.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp_context():
|
||||
"""Mock MCP context for testing.
|
||||
|
||||
Use this fixture to test MCP server logic without real MCP connections.
|
||||
"""
|
||||
context = AsyncMock()
|
||||
context.request_context = AsyncMock()
|
||||
context.session = Mock()
|
||||
context.session.send_resource_updated = AsyncMock()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_computer():
|
||||
"""Mock Computer instance for MCP server tests.
|
||||
|
||||
Use this fixture to test MCP logic without real Computer operations.
|
||||
"""
|
||||
computer = AsyncMock()
|
||||
computer.interface = AsyncMock()
|
||||
computer.interface.screenshot = AsyncMock(return_value=b"fake_screenshot")
|
||||
computer.interface.left_click = AsyncMock()
|
||||
computer.interface.type = AsyncMock()
|
||||
|
||||
# Mock context manager
|
||||
computer.__aenter__ = AsyncMock(return_value=computer)
|
||||
computer.__aexit__ = AsyncMock()
|
||||
|
||||
return computer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_telemetry(monkeypatch):
|
||||
"""Disable telemetry for tests.
|
||||
|
||||
Use this fixture to ensure no telemetry is sent during tests.
|
||||
"""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Unit tests for mcp-server package.
|
||||
|
||||
This file tests ONLY basic MCP server functionality.
|
||||
Following SRP: This file tests MCP server initialization.
|
||||
All external dependencies are mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestMCPServerImports:
|
||||
"""Test MCP server module imports (SRP: Only tests imports)."""
|
||||
|
||||
def test_mcp_server_module_exists(self):
|
||||
"""Test that mcp_server module can be imported."""
|
||||
try:
|
||||
import mcp_server
|
||||
|
||||
assert mcp_server is not None
|
||||
except ImportError:
|
||||
pytest.skip("mcp_server module not installed")
|
||||
except SystemExit:
|
||||
pytest.skip("MCP dependencies (mcp.server.fastmcp) not available")
|
||||
|
||||
|
||||
class TestMCPServerInitialization:
|
||||
"""Test MCP server initialization (SRP: Only tests initialization)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_can_be_imported(self):
|
||||
"""Basic smoke test: verify MCP server components can be imported."""
|
||||
try:
|
||||
from mcp_server import server
|
||||
|
||||
assert server is not None
|
||||
except ImportError:
|
||||
pytest.skip("MCP server module not available")
|
||||
except SystemExit:
|
||||
pytest.skip("MCP dependencies (mcp.server.fastmcp) not available")
|
||||
except Exception as e:
|
||||
# Some initialization errors are acceptable in unit tests
|
||||
pytest.skip(f"MCP server initialization requires specific setup: {e}")
|
||||
Reference in New Issue
Block a user