Files
python-sdk/pkg/hanzo-tools-computer/hanzo_tools/computer/computer_tool.py
T
zeekay 2cb7eb03fc feat(hanzo-mcp): unified tool command and essential tools system
Major improvements to mode and tool management:

1. Essential System Tools - Critical tools (llm, consensus, config, mode,
   tool, version, stats) are now ALWAYS enabled regardless of active mode.
   Previously, mode system disabled all tools then only enabled mode-specific
   ones, causing critical tools to be unavailable.

2. Unified 'tool' Command - Consolidates tool_install, tool_enable,
   tool_disable, tool_list into single 'tool' command with actions:
   - list: List all tools by category
   - enable/disable: Toggle tool state
   - install/uninstall: Package management
   - upgrade/reload: Update tools
   - self_update: Update hanzo-mcp

3. Fixed Permission Manager - Made permission_manager optional in:
   - FileSystemTool base class
   - ComputerTool
   - ConfigTool
   - ReadTool, WriteTool, EditTool
   Now creates default PermissionManager if not provided.

4. Fixed Mode System - Builtin modes (hanzo, minimal, fullstack, devops,
   security) are now always registered alongside hanzo-persona profiles.
   Previously they were skipped when hanzo-persona was available.

5. Updated ESSENTIAL_TOOLS constant with modern tool names and added
   llm, consensus, config, mode to ensure they're in all default modes.

Version bump: 0.10.19 -> 0.10.20
2025-12-26 11:09:45 -08:00

1042 lines
40 KiB
Python

"""Computer control tool using pyautogui for Mac automation.
Provides unified control over mouse, keyboard, screen capture, window management,
and image location. All operations run in executor threads to avoid blocking.
Performance optimizations:
- Lazy loading of pyautogui/PIL (150ms+ startup savings)
- Cached screen size and display info
- Thread pool executor for all blocking operations
- Batch operations for multiple actions
- Async-first design with no blocking in event loop
"""
import asyncio
import base64
import functools
import io
import json
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Annotated, Any, Literal, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from pydantic import Field
from hanzo_tools.core import BaseTool, PermissionManager, auto_timeout
# Shared thread pool for blocking operations (faster than run_in_executor default)
_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="computer_")
# All supported actions
Action = Literal[
# Mouse actions
"click", "double_click", "right_click", "middle_click",
"move", "move_relative", "drag", "drag_relative", "scroll",
# Keyboard actions
"type", "write", "press", "key_down", "key_up", "hotkey",
# Screen actions
"screenshot", "screenshot_region",
# Image location
"locate", "locate_all", "locate_center", "wait_for_image", "wait_while_image",
# Pixel operations
"pixel", "pixel_matches",
# Window management
"get_active_window", "list_windows", "focus_window",
# Screen management
"get_screens", "screen_size", "current_screen",
# Region helpers
"define_region", "region_screenshot", "region_locate",
# Timing/flow
"sleep", "countdown", "set_pause", "set_failsafe",
# Batch operations
"batch",
# Info
"info", "position",
]
@final
class ComputerTool(BaseTool):
"""Control local computer via pyautogui.
Comprehensive Mac automation supporting mouse, keyboard, screen capture,
window management, image location, and timing controls.
"""
name = "computer"
def __init__(self, permission_manager: Optional[PermissionManager] = None):
"""Initialize the computer tool.
Args:
permission_manager: Permission manager for access control (auto-created if None)
"""
if permission_manager is None:
permission_manager = PermissionManager()
self.permission_manager = permission_manager
self._pyautogui = None
self._pil = None
self._defined_regions: dict[str, tuple[int, int, int, int]] = {}
self._pause = 0.1
self._failsafe = True
# Caches
self._screen_size_cache: tuple[int, int] | None = None
self._screen_size_time: float = 0
self._displays_cache: str | None = None
self._displays_time: float = 0
self._CACHE_TTL = 5.0 # Cache for 5 seconds
def _ensure_pyautogui(self):
"""Lazy load pyautogui to avoid startup cost."""
if self._pyautogui is None:
import pyautogui
pyautogui.FAILSAFE = self._failsafe
pyautogui.PAUSE = self._pause
self._pyautogui = pyautogui
return self._pyautogui
def _ensure_pil(self):
"""Lazy load PIL."""
if self._pil is None:
from PIL import Image
self._pil = Image
return self._pil
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Control local computer via pyautogui for comprehensive Mac automation.
Actions:
MOUSE:
- click(x, y) / double_click / right_click / middle_click
- move(x, y, duration) / move_relative(dx, dy)
- drag(x, y, duration) / drag_relative(dx, dy)
- scroll(amount, x, y)
KEYBOARD:
- type(text, interval): Type text character by character
- write(text, clear): Type text, optionally clear first
- press(key): Press and release a key
- key_down(key) / key_up(key): Hold/release modifier
- hotkey(keys): Press key combination like ["command", "c"]
SCREEN:
- screenshot() / screenshot_region(region)
- get_screens(): List all displays
- screen_size() / current_screen()
IMAGE LOCATION:
- locate(image_path): Find image on screen, return center
- locate_all(image_path): Find all matches
- locate_center(image_path): Return just center point
- wait_for_image(image_path, timeout): Wait until image appears
- wait_while_image(image_path, timeout): Wait while image visible
PIXEL:
- pixel(x, y): Get pixel color at point
- pixel_matches(x, y, color, tolerance): Check if pixel matches
WINDOWS:
- get_active_window(): Get frontmost window info
- list_windows(): List all windows with bounds
- focus_window(title, regex): Bring window to front
REGIONS:
- define_region(name, x, y, w, h): Name a region for reuse
- region_screenshot(name): Screenshot named region
- region_locate(name, image): Find image in named region
TIMING:
- sleep(seconds)
- countdown(seconds): Sleep with countdown output
- set_pause(seconds): Set global pause between actions
- set_failsafe(enabled): Enable/disable corner abort
INFO:
- info(): Screen size and mouse position
- position(): Current mouse position
Examples:
computer(action="click", x=100, y=200)
computer(action="write", text="Hello", clear=True)
computer(action="hotkey", keys=["command", "v"])
computer(action="wait_for_image", image_path="button.png", timeout=10)
computer(action="focus_window", title="Terminal")
computer(action="define_region", name="toolbar", x=0, y=0, width=1920, height=60)
computer(action="set_pause", value=0.2)
"""
@override
@auto_timeout("computer")
async def call(
self,
ctx: MCPContext,
action: str = "info",
# Coordinates
x: int | None = None,
y: int | None = None,
dx: int | None = None,
dy: int | None = None,
# Text/keys
text: str | None = None,
key: str | None = None,
keys: list[str] | None = None,
# Options
amount: int | None = None,
duration: float = 0.25,
interval: float = 0.02,
region: list[int] | None = None,
clear: bool = False,
# Image location
image_path: str | None = None,
confidence: float = 0.9,
timeout: float = 10.0,
# Pixel matching
color: tuple[int, int, int] | list[int] | None = None,
tolerance: int = 0,
# Window
title: str | None = None,
use_regex: bool = False,
# Regions
name: str | None = None,
width: int | None = None,
height: int | None = None,
# Settings
value: float | bool | None = None,
# Batch operations
actions: list[dict[str, Any]] | None = None,
**kwargs,
) -> str:
"""Execute computer control action."""
# Check platform
if sys.platform != "darwin":
return f"Error: computer tool only supports macOS, got {sys.platform}"
loop = asyncio.get_event_loop()
# Helper for running in thread pool
def run(fn, *args):
return loop.run_in_executor(_EXECUTOR, fn, *args)
try:
# Mouse actions
if action == "click":
if x is None or y is None:
return "Error: click requires x and y coordinates"
return await run(self._click, x, y, "left")
elif action == "double_click":
if x is None or y is None:
return "Error: double_click requires x and y coordinates"
return await run(self._double_click, x, y)
elif action == "right_click":
if x is None or y is None:
return "Error: right_click requires x and y coordinates"
return await run(self._click, x, y, "right")
elif action == "middle_click":
if x is None or y is None:
return "Error: middle_click requires x and y coordinates"
return await run(self._click, x, y, "middle")
elif action == "move":
if x is None or y is None:
return "Error: move requires x and y coordinates"
return await run(self._move, x, y, duration)
elif action == "move_relative":
if dx is None or dy is None:
return "Error: move_relative requires dx and dy"
return await run(self._move_relative, dx, dy, duration)
elif action == "drag":
if x is None or y is None:
return "Error: drag requires x and y coordinates"
return await run(self._drag, x, y, duration)
elif action == "drag_relative":
if dx is None or dy is None:
return "Error: drag_relative requires dx and dy"
return await run(self._drag_relative, dx, dy, duration)
elif action == "scroll":
if amount is None:
return "Error: scroll requires amount"
return await run(self._scroll, amount, x, y)
# Keyboard actions
elif action == "type":
if not text:
return "Error: type requires text"
return await run(self._type_text, text, interval)
elif action == "write":
if not text:
return "Error: write requires text"
return await run(self._write_text, text, clear, interval)
elif action == "press":
if not key:
return "Error: press requires key"
return await run(self._press_key, key)
elif action == "key_down":
if not key:
return "Error: key_down requires key"
return await run(self._key_down, key)
elif action == "key_up":
if not key:
return "Error: key_up requires key"
return await run(self._key_up, key)
elif action == "hotkey":
if not keys:
return "Error: hotkey requires keys list"
return await run(self._hotkey, keys)
# Screen actions
elif action == "screenshot":
return await run(self._screenshot, None)
elif action == "screenshot_region":
if region is None:
return "Error: screenshot_region requires region [x, y, width, height]"
return await run(self._screenshot, region)
# Image location
elif action == "locate":
if not image_path and not text:
return "Error: locate requires image_path"
path = image_path or text
return await run(self._locate, path, confidence, None)
elif action == "locate_all":
if not image_path and not text:
return "Error: locate_all requires image_path"
path = image_path or text
return await run(self._locate_all, path, confidence)
elif action == "locate_center":
if not image_path and not text:
return "Error: locate_center requires image_path"
path = image_path or text
return await run(self._locate_center, path, confidence)
elif action == "wait_for_image":
if not image_path and not text:
return "Error: wait_for_image requires image_path"
path = image_path or text
return await self._wait_for_image(path, timeout, confidence, run)
elif action == "wait_while_image":
if not image_path and not text:
return "Error: wait_while_image requires image_path"
path = image_path or text
return await self._wait_while_image(path, timeout, confidence, run)
# Pixel operations
elif action == "pixel":
if x is None or y is None:
return "Error: pixel requires x and y coordinates"
return await run(self._get_pixel, x, y)
elif action == "pixel_matches":
if x is None or y is None:
return "Error: pixel_matches requires x and y coordinates"
if color is None:
return "Error: pixel_matches requires color (RGB tuple)"
return await run(self._pixel_matches, x, y, tuple(color), tolerance)
# Window management
elif action == "get_active_window":
return await run(self._get_active_window)
elif action == "list_windows":
return await run(self._list_windows)
elif action == "focus_window":
if not title and not text:
return "Error: focus_window requires title"
win_title = title or text
return await run(self._focus_window, win_title, use_regex)
# Screen management
elif action == "get_screens":
return await run(self._get_screens_cached)
elif action == "screen_size":
return await run(self._screen_size_cached)
elif action == "current_screen":
return await run(self._current_screen)
# Region helpers
elif action == "define_region":
if not name:
return "Error: define_region requires name"
if x is None or y is None or width is None or height is None:
return "Error: define_region requires x, y, width, height"
self._defined_regions[name] = (x, y, width, height)
return f"Defined region '{name}': ({x}, {y}, {width}, {height})"
elif action == "region_screenshot":
if not name:
return "Error: region_screenshot requires region name"
if name not in self._defined_regions:
return f"Error: Region '{name}' not defined"
reg = list(self._defined_regions[name])
return await run(self._screenshot, reg)
elif action == "region_locate":
if not name:
return "Error: region_locate requires region name"
if not image_path and not text:
return "Error: region_locate requires image_path"
if name not in self._defined_regions:
return f"Error: Region '{name}' not defined"
path = image_path or text
reg = self._defined_regions[name]
return await run(self._locate, path, confidence, reg)
# Timing/flow
elif action == "sleep":
if value is None:
return "Error: sleep requires value (seconds)"
await asyncio.sleep(float(value))
return f"Slept for {value} seconds"
elif action == "countdown":
if value is None:
return "Error: countdown requires value (seconds)"
return await self._countdown(int(value))
elif action == "set_pause":
if value is None:
return "Error: set_pause requires value (seconds)"
self._pause = float(value)
if self._pyautogui:
self._pyautogui.PAUSE = self._pause
return f"Set global pause to {self._pause}s"
elif action == "set_failsafe":
if value is None:
return "Error: set_failsafe requires value (true/false)"
self._failsafe = bool(value)
if self._pyautogui:
self._pyautogui.FAILSAFE = self._failsafe
return f"Failsafe {'enabled' if self._failsafe else 'disabled'}"
# Info
elif action == "info":
return await run(self._get_info)
elif action == "position":
return await run(self._get_position)
# Batch operations - run multiple actions in one call
elif action == "batch":
if not actions:
return "Error: batch requires actions list"
return await self._run_batch(actions, run)
else:
return f"Error: Unknown action '{action}'"
except Exception as e:
return f"Error: {str(e)}"
# ========== Mouse Methods ==========
def _click(self, x: int, y: int, button: str = "left") -> str:
pg = self._ensure_pyautogui()
pg.click(x, y, button=button)
return f"Clicked {button} at ({x}, {y})"
def _double_click(self, x: int, y: int) -> str:
pg = self._ensure_pyautogui()
pg.doubleClick(x, y)
return f"Double clicked at ({x}, {y})"
def _move(self, x: int, y: int, duration: float) -> str:
pg = self._ensure_pyautogui()
pg.moveTo(x, y, duration=duration)
return f"Moved to ({x}, {y})"
def _move_relative(self, dx: int, dy: int, duration: float) -> str:
pg = self._ensure_pyautogui()
pg.moveRel(dx, dy, duration=duration)
pos = pg.position()
return f"Moved by ({dx}, {dy}) to ({pos.x}, {pos.y})"
def _drag(self, x: int, y: int, duration: float) -> str:
pg = self._ensure_pyautogui()
pg.dragTo(x, y, duration=duration)
return f"Dragged to ({x}, {y})"
def _drag_relative(self, dx: int, dy: int, duration: float) -> str:
pg = self._ensure_pyautogui()
pg.dragRel(dx, dy, duration=duration)
pos = pg.position()
return f"Dragged by ({dx}, {dy}) to ({pos.x}, {pos.y})"
def _scroll(self, amount: int, x: int | None, y: int | None) -> str:
pg = self._ensure_pyautogui()
if x is not None and y is not None:
pg.scroll(amount, x=x, y=y)
return f"Scrolled {amount} at ({x}, {y})"
else:
pg.scroll(amount)
return f"Scrolled {amount}"
# ========== Keyboard Methods ==========
def _type_text(self, text: str, interval: float) -> str:
pg = self._ensure_pyautogui()
pg.typewrite(text, interval=interval)
return f"Typed {len(text)} characters"
def _write_text(self, text: str, clear: bool, interval: float) -> str:
pg = self._ensure_pyautogui()
if clear:
pg.hotkey("command", "a")
time.sleep(0.05)
pg.write(text, interval=interval)
return f"Wrote {len(text)} characters" + (" (cleared first)" if clear else "")
def _press_key(self, key: str) -> str:
pg = self._ensure_pyautogui()
pg.press(key)
return f"Pressed '{key}'"
def _key_down(self, key: str) -> str:
pg = self._ensure_pyautogui()
pg.keyDown(key)
return f"Key down: '{key}'"
def _key_up(self, key: str) -> str:
pg = self._ensure_pyautogui()
pg.keyUp(key)
return f"Key up: '{key}'"
def _hotkey(self, keys: list[str]) -> str:
pg = self._ensure_pyautogui()
pg.hotkey(*keys)
return f"Pressed hotkey: {'+'.join(keys)}"
# ========== Screen Methods ==========
def _screenshot(self, region: list[int] | None) -> str:
pg = self._ensure_pyautogui()
self._ensure_pil()
if region and len(region) == 4:
screenshot = pg.screenshot(region=tuple(region))
else:
screenshot = pg.screenshot()
buffer = io.BytesIO()
screenshot.save(buffer, format="PNG")
img_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"Screenshot captured ({screenshot.width}x{screenshot.height})\ndata:image/png;base64,{img_data[:100]}...[truncated]"
def _get_screens(self) -> str:
"""Get all display info using AppKit."""
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType", "-json"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
data = json.loads(result.stdout)
displays = []
for gpu in data.get("SPDisplaysDataType", []):
for disp in gpu.get("spdisplays_ndrvs", []):
displays.append({
"name": disp.get("_name", "Unknown"),
"resolution": disp.get("_spdisplays_resolution", "Unknown"),
"main": disp.get("spdisplays_main") == "spdisplays_yes",
})
return json.dumps(displays, indent=2)
except Exception as e:
pass
pg = self._ensure_pyautogui()
size = pg.size()
return json.dumps([{"name": "Primary", "resolution": f"{size.width}x{size.height}", "main": True}])
def _screen_size(self) -> str:
pg = self._ensure_pyautogui()
size = pg.size()
return f"{size.width}x{size.height}"
def _screen_size_cached(self) -> str:
"""Cached screen size (5 second TTL)."""
now = time.time()
if self._screen_size_cache and (now - self._screen_size_time) < self._CACHE_TTL:
return f"{self._screen_size_cache[0]}x{self._screen_size_cache[1]}"
pg = self._ensure_pyautogui()
size = pg.size()
self._screen_size_cache = (size.width, size.height)
self._screen_size_time = now
return f"{size.width}x{size.height}"
def _get_screens_cached(self) -> str:
"""Cached screen list (5 second TTL)."""
now = time.time()
if self._displays_cache and (now - self._displays_time) < self._CACHE_TTL:
return self._displays_cache
result = self._get_screens()
self._displays_cache = result
self._displays_time = now
return result
def _current_screen(self) -> str:
pg = self._ensure_pyautogui()
pos = pg.position()
size = pg.size()
return json.dumps({
"size": {"width": size.width, "height": size.height},
"mouse": {"x": pos.x, "y": pos.y},
})
# ========== Image Location Methods ==========
def _locate(
self, image_path: str, confidence: float, region: tuple | None
) -> str:
pg = self._ensure_pyautogui()
path = Path(image_path)
if not path.exists():
return f"Error: Image not found: {image_path}"
try:
kwargs: dict[str, Any] = {}
if confidence < 1.0:
kwargs["confidence"] = confidence
if region:
kwargs["region"] = region
location = pg.locateOnScreen(str(path), **kwargs)
if location:
center = pg.center(location)
return json.dumps({
"found": True,
"center": {"x": center.x, "y": center.y},
"box": {"left": location.left, "top": location.top,
"width": location.width, "height": location.height},
})
else:
return json.dumps({"found": False, "message": "Image not found on screen"})
except Exception as e:
return f"Error locating image: {str(e)}"
def _locate_all(self, image_path: str, confidence: float) -> str:
pg = self._ensure_pyautogui()
path = Path(image_path)
if not path.exists():
return f"Error: Image not found: {image_path}"
try:
kwargs: dict[str, Any] = {}
if confidence < 1.0:
kwargs["confidence"] = confidence
locations = list(pg.locateAllOnScreen(str(path), **kwargs))
results = []
for loc in locations:
center = pg.center(loc)
results.append({
"center": {"x": center.x, "y": center.y},
"box": {"left": loc.left, "top": loc.top,
"width": loc.width, "height": loc.height},
})
return json.dumps({"found": len(results), "locations": results})
except Exception as e:
return f"Error locating images: {str(e)}"
def _locate_center(self, image_path: str, confidence: float) -> str:
pg = self._ensure_pyautogui()
path = Path(image_path)
if not path.exists():
return f"Error: Image not found: {image_path}"
try:
kwargs: dict[str, Any] = {}
if confidence < 1.0:
kwargs["confidence"] = confidence
center = pg.locateCenterOnScreen(str(path), **kwargs)
if center:
return json.dumps({"found": True, "x": center.x, "y": center.y})
else:
return json.dumps({"found": False})
except Exception as e:
return f"Error: {str(e)}"
async def _wait_for_image(
self, image_path: str, timeout: float, confidence: float, run
) -> str:
"""Wait until image appears on screen."""
path = Path(image_path)
if not path.exists():
return f"Error: Image not found: {image_path}"
start = time.time()
while time.time() - start < timeout:
result = await run(self._locate_center, str(path), confidence)
data = json.loads(result) if result.startswith("{") else {}
if data.get("found"):
elapsed = time.time() - start
return json.dumps({
"found": True,
"x": data["x"],
"y": data["y"],
"elapsed": round(elapsed, 2),
})
await asyncio.sleep(0.1) # Faster polling
return json.dumps({"found": False, "timeout": timeout})
async def _wait_while_image(
self, image_path: str, timeout: float, confidence: float, run
) -> str:
"""Wait while image is visible on screen."""
path = Path(image_path)
if not path.exists():
return f"Error: Image not found: {image_path}"
start = time.time()
while time.time() - start < timeout:
result = await run(self._locate_center, str(path), confidence)
data = json.loads(result) if result.startswith("{") else {}
if not data.get("found"):
elapsed = time.time() - start
return json.dumps({"disappeared": True, "elapsed": round(elapsed, 2)})
await asyncio.sleep(0.1) # Faster polling
return json.dumps({"disappeared": False, "timeout": timeout, "still_visible": True})
# ========== Pixel Methods ==========
def _get_pixel(self, x: int, y: int) -> str:
pg = self._ensure_pyautogui()
try:
screenshot = pg.screenshot(region=(x, y, 1, 1))
pixel = screenshot.getpixel((0, 0))
return json.dumps({"x": x, "y": y, "color": {"r": pixel[0], "g": pixel[1], "b": pixel[2]}})
except Exception as e:
return f"Error: {str(e)}"
def _pixel_matches(
self, x: int, y: int, color: tuple[int, int, int], tolerance: int
) -> str:
pg = self._ensure_pyautogui()
try:
screenshot = pg.screenshot(region=(x, y, 1, 1))
pixel = screenshot.getpixel((0, 0))
matches = all(
abs(pixel[i] - color[i]) <= tolerance for i in range(3)
)
return json.dumps({
"matches": matches,
"expected": {"r": color[0], "g": color[1], "b": color[2]},
"actual": {"r": pixel[0], "g": pixel[1], "b": pixel[2]},
"tolerance": tolerance,
})
except Exception as e:
return f"Error: {str(e)}"
# ========== Window Management Methods ==========
def _get_active_window(self) -> str:
"""Get frontmost window info using AppleScript."""
try:
script = '''
tell application "System Events"
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set frontWindow to front window of frontApp
set winName to name of frontWindow
set winPos to position of frontWindow
set winSize to size of frontWindow
return appName & "|||" & winName & "|||" & (item 1 of winPos as string) & "," & (item 2 of winPos as string) & "|||" & (item 1 of winSize as string) & "," & (item 2 of winSize as string)
on error
return appName & "|||" & "" & "|||0,0|||0,0"
end try
end tell
'''
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
parts = result.stdout.strip().split("|||")
if len(parts) >= 4:
pos = parts[2].split(",")
size = parts[3].split(",")
return json.dumps({
"app": parts[0],
"title": parts[1],
"position": {"x": int(pos[0]), "y": int(pos[1])},
"size": {"width": int(size[0]), "height": int(size[1])},
})
return json.dumps({"error": "Could not get active window"})
except Exception as e:
return json.dumps({"error": str(e)})
def _list_windows(self) -> str:
"""List all windows using AppleScript."""
try:
script = '''
set windowList to ""
tell application "System Events"
set allProcesses to application processes whose visible is true
repeat with proc in allProcesses
set procName to name of proc
try
set procWindows to windows of proc
repeat with win in procWindows
set winName to name of win
set winPos to position of win
set winSize to size of win
set windowList to windowList & procName & "|||" & winName & "|||" & (item 1 of winPos as string) & "," & (item 2 of winPos as string) & "|||" & (item 1 of winSize as string) & "," & (item 2 of winSize as string) & "\\n"
end repeat
end try
end repeat
end tell
return windowList
'''
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
windows = []
for line in result.stdout.strip().split("\n"):
if "|||" in line:
parts = line.split("|||")
if len(parts) >= 4:
pos = parts[2].split(",")
size = parts[3].split(",")
windows.append({
"app": parts[0],
"title": parts[1],
"position": {"x": int(pos[0]), "y": int(pos[1])},
"size": {"width": int(size[0]), "height": int(size[1])},
})
return json.dumps({"windows": windows, "count": len(windows)})
return json.dumps({"error": "Could not list windows", "windows": []})
except Exception as e:
return json.dumps({"error": str(e), "windows": []})
def _focus_window(self, title: str, use_regex: bool) -> str:
"""Focus a window by title."""
try:
if use_regex:
# First list windows and find match
windows_json = self._list_windows()
windows_data = json.loads(windows_json)
pattern = re.compile(title)
for win in windows_data.get("windows", []):
if pattern.search(win.get("title", "")) or pattern.search(win.get("app", "")):
title = win.get("app")
break
script = f'''
tell application "{title}"
activate
end tell
'''
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return json.dumps({"focused": True, "title": title})
return json.dumps({"focused": False, "error": result.stderr})
except Exception as e:
return json.dumps({"focused": False, "error": str(e)})
# ========== Timing Methods ==========
async def _countdown(self, seconds: int) -> str:
"""Countdown with output."""
for i in range(seconds, 0, -1):
await asyncio.sleep(1)
return f"Countdown complete ({seconds}s)"
async def _run_batch(self, actions: list[dict[str, Any]], run) -> str:
"""Run multiple actions in sequence, returning all results.
Example:
computer(action="batch", actions=[
{"action": "move", "x": 100, "y": 200},
{"action": "click", "x": 100, "y": 200},
{"action": "type", "text": "hello"},
])
"""
results = []
for i, action_spec in enumerate(actions):
if not isinstance(action_spec, dict):
results.append({"index": i, "error": "Invalid action spec"})
continue
action = action_spec.get("action", "")
if not action:
results.append({"index": i, "error": "Missing action"})
continue
# Build kwargs from action spec
kwargs = {k: v for k, v in action_spec.items() if k != "action"}
# Execute action using the appropriate method
try:
result = await self._execute_single_action(action, kwargs, run)
results.append({"index": i, "action": action, "result": result})
except Exception as e:
results.append({"index": i, "action": action, "error": str(e)})
return json.dumps({"batch_results": results, "count": len(results)})
async def _execute_single_action(self, action: str, params: dict, run) -> str:
"""Execute a single action with params (for batch operations)."""
x = params.get("x")
y = params.get("y")
dx = params.get("dx")
dy = params.get("dy")
duration = params.get("duration", 0.25)
# Mouse
if action == "click":
return await run(self._click, x, y, params.get("button", "left"))
elif action == "double_click":
return await run(self._double_click, x, y)
elif action == "right_click":
return await run(self._click, x, y, "right")
elif action == "move":
return await run(self._move, x, y, duration)
elif action == "move_relative":
return await run(self._move_relative, dx, dy, duration)
elif action == "drag":
return await run(self._drag, x, y, duration)
elif action == "scroll":
return await run(self._scroll, params.get("amount", 0), x, y)
# Keyboard
elif action == "type":
return await run(self._type_text, params.get("text", ""), params.get("interval", 0.02))
elif action == "write":
return await run(self._write_text, params.get("text", ""), params.get("clear", False), params.get("interval", 0.02))
elif action == "press":
return await run(self._press_key, params.get("key", ""))
elif action == "key_down":
return await run(self._key_down, params.get("key", ""))
elif action == "key_up":
return await run(self._key_up, params.get("key", ""))
elif action == "hotkey":
return await run(self._hotkey, params.get("keys", []))
# Screen
elif action == "screenshot":
return await run(self._screenshot, params.get("region"))
# Timing
elif action == "sleep":
await asyncio.sleep(float(params.get("value", 0)))
return f"Slept {params.get('value', 0)}s"
else:
return f"Unsupported batch action: {action}"
# ========== Info Methods ==========
def _get_info(self) -> str:
pg = self._ensure_pyautogui()
size = pg.size()
pos = pg.position()
return json.dumps({
"screen": {"width": size.width, "height": size.height},
"mouse": {"x": pos.x, "y": pos.y},
"pause": self._pause,
"failsafe": self._failsafe,
"regions": list(self._defined_regions.keys()),
})
def _get_position(self) -> str:
pg = self._ensure_pyautogui()
pos = pg.position()
return json.dumps({"x": pos.x, "y": pos.y})
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def computer(
action: Annotated[str, Field(description="Action to perform")] = "info",
x: Annotated[int | None, Field(description="X coordinate")] = None,
y: Annotated[int | None, Field(description="Y coordinate")] = None,
dx: Annotated[int | None, Field(description="Delta X for relative movement")] = None,
dy: Annotated[int | None, Field(description="Delta Y for relative movement")] = None,
text: Annotated[str | None, Field(description="Text for type/write actions")] = None,
key: Annotated[str | None, Field(description="Key for press/key_down/key_up")] = None,
keys: Annotated[list[str] | None, Field(description="Keys for hotkey")] = None,
amount: Annotated[int | None, Field(description="Scroll amount")] = None,
duration: Annotated[float, Field(description="Movement duration")] = 0.25,
interval: Annotated[float, Field(description="Typing interval")] = 0.02,
region: Annotated[list[int] | None, Field(description="Region [x, y, w, h]")] = None,
clear: Annotated[bool, Field(description="Clear before write")] = False,
image_path: Annotated[str | None, Field(description="Image path for locate")] = None,
confidence: Annotated[float, Field(description="Image match confidence")] = 0.9,
timeout: Annotated[float, Field(description="Wait timeout in seconds")] = 10.0,
color: Annotated[list[int] | None, Field(description="RGB color for pixel_matches")] = None,
tolerance: Annotated[int, Field(description="Color tolerance")] = 0,
title: Annotated[str | None, Field(description="Window title")] = None,
use_regex: Annotated[bool, Field(description="Use regex for title match")] = False,
name: Annotated[str | None, Field(description="Region name")] = None,
width: Annotated[int | None, Field(description="Width for region")] = None,
height: Annotated[int | None, Field(description="Height for region")] = None,
value: Annotated[float | None, Field(description="Value for settings")] = None,
actions: Annotated[list[dict] | None, Field(description="Batch actions list")] = None,
ctx: MCPContext = None,
) -> str:
return await tool_self.call(
ctx,
action=action,
x=x, y=y, dx=dx, dy=dy,
text=text, key=key, keys=keys,
amount=amount, duration=duration, interval=interval,
region=region, clear=clear,
image_path=image_path, confidence=confidence, timeout=timeout,
color=tuple(color) if color else None, tolerance=tolerance,
title=title, use_regex=use_regex,
name=name, width=width, height=height,
value=value,
actions=actions,
)