computer-control-mcp 0.2.7__tar.gz → 0.2.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2023 Computer Control MCP Authors
3
+ Copyright (c) 2025 AB498 <abcd49800@gmail.com>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: computer-control-mcp
3
- Version: 0.2.7
4
- Summary: Computer control capabilities using PyAutoGUI through a Model Context Protocol (MCP) server
3
+ Version: 0.2.8
4
+ Summary: MCP server that provides computer control capabilities, like mouse, keyboard, OCR, etc. using PyAutoGUI, RapidOCR, ONNXRuntime. Similar to 'computer-use' by Anthropic. With Zero External Dependencies.
5
5
  Project-URL: Homepage, https://github.com/AB498/computer-control-mcp
6
6
  Project-URL: Issues, https://github.com/AB498/computer-control-mcp/issues
7
7
  Project-URL: Documentation, https://github.com/AB498/computer-control-mcp#readme
8
- Author-email: Computer Control MCP Authors <example@example.com>
8
+ Author-email: AB498 <abcd49800@gmail.com>
9
9
  License: MIT
10
10
  License-File: LICENSE
11
11
  Classifier: Development Status :: 4 - Beta
@@ -16,29 +16,37 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Topic :: Software Development :: Libraries
17
17
  Classifier: Topic :: Utilities
18
18
  Requires-Python: >=3.12
19
- Requires-Dist: fuzzywuzzy
20
- Requires-Dist: mcp[cli]
21
- Requires-Dist: onnxruntime
22
- Requires-Dist: opencv-python
23
- Requires-Dist: pillow
24
- Requires-Dist: pyautogui
25
- Requires-Dist: pygetwindow
26
- Requires-Dist: rapidocr
27
- Requires-Dist: rapidocr-onnxruntime
19
+ Requires-Dist: fuzzywuzzy==0.18.0
20
+ Requires-Dist: mcp[cli]==1.13.0
21
+ Requires-Dist: onnxruntime==1.22.1
22
+ Requires-Dist: opencv-python==4.12.0.88
23
+ Requires-Dist: pillow==11.3.0
24
+ Requires-Dist: pyautogui==0.9.54
25
+ Requires-Dist: pygetwindow==0.0.9
26
+ Requires-Dist: rapidocr-onnxruntime==1.4.4
27
+ Requires-Dist: rapidocr==3.3.1
28
28
  Description-Content-Type: text/markdown
29
29
 
30
30
  # Computer Control MCP
31
31
 
32
- MCP server that provides computer control capabilities, like mouse, keyboard, OCR, etc. using PyAutoGUI, RapidOCR, ONNXRuntime. With Zero External Dependencies.
32
+ ### MCP server that provides computer control capabilities, like mouse, keyboard, OCR, etc. using PyAutoGUI, RapidOCR, ONNXRuntime. Similar to 'computer-use' by Anthropic. With Zero External Dependencies.
33
33
 
34
- * ***Only tested on Windows. Contributions are welcome.***
34
+ * *Only tested on Windows. Should work on other platforms.*
35
35
 
36
- ## Example
36
+ <div style="text-align:center;font-family: monospace; display: flex; align-items: center; justify-content: center; width: 100%; gap: 10px">
37
+ <a href="https://discord.gg/ZeeqSBpjU2"><img src="https://img.shields.io/discord/1095854826786668545" alt="Discord"></a>
38
+ <a href="https://img.shields.io/badge/License-MIT-yellow.svg"><img
39
+ src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
40
+ </div>
37
41
 
38
- ![MCP Computer Control Demo](demonstration.gif)
42
+ ---
43
+
44
+ ![MCP Computer Control Demo](https://github.com/AB498/computer-control-mcp/blob/main/demonstration.gif?raw=true)
39
45
 
40
46
  ## Quick Usage (MCP Setup Using `uvx`)
41
47
 
48
+ ***Note:** Running `uvx computer-control-mcp@latest` for the first time will download python dependencies (around 70MB) which may take some time. Recommended to run this in a terminal before using it as MCP. Subsequent runs will be instant.*
49
+
42
50
  ```json
43
51
  {
44
52
  "mcpServers": {
@@ -86,14 +94,6 @@ computer-control-mcp # instead of uvx computer-control-mcp, so you can use the l
86
94
  - `list_windows()`: List all open windows
87
95
  - `activate_window(title_pattern: str, use_regex: bool = False, threshold: int = 60)`: Bring specified window to foreground
88
96
 
89
- ### Running as a Module
90
-
91
- You can run the package as a module:
92
-
93
- ```bash
94
- python -m computer_control_mcp
95
- ```
96
-
97
97
  ## Development
98
98
 
99
99
  ### Setting up the Development Environment
@@ -105,6 +105,9 @@ cd computer-control-mcp
105
105
 
106
106
  # Install in development mode
107
107
  pip install -e .
108
+
109
+ # Start server
110
+ python -m computer_control_mcp.core
108
111
  ```
109
112
 
110
113
  ### Running Tests
@@ -120,3 +123,8 @@ See the [API Reference](docs/api.md) for detailed information about the availabl
120
123
  ## License
121
124
 
122
125
  MIT
126
+
127
+ ## For more information or help
128
+
129
+ - [Email (abcd49800@gmail.com)](mailto:abcd49800@gmail.com)
130
+ - [Discord (CodePlayground)](https://discord.gg/ZeeqSBpjU2)
@@ -1,15 +1,23 @@
1
1
  # Computer Control MCP
2
2
 
3
- MCP server that provides computer control capabilities, like mouse, keyboard, OCR, etc. using PyAutoGUI, RapidOCR, ONNXRuntime. With Zero External Dependencies.
3
+ ### MCP server that provides computer control capabilities, like mouse, keyboard, OCR, etc. using PyAutoGUI, RapidOCR, ONNXRuntime. Similar to 'computer-use' by Anthropic. With Zero External Dependencies.
4
4
 
5
- * ***Only tested on Windows. Contributions are welcome.***
5
+ * *Only tested on Windows. Should work on other platforms.*
6
6
 
7
- ## Example
7
+ <div style="text-align:center;font-family: monospace; display: flex; align-items: center; justify-content: center; width: 100%; gap: 10px">
8
+ <a href="https://discord.gg/ZeeqSBpjU2"><img src="https://img.shields.io/discord/1095854826786668545" alt="Discord"></a>
9
+ <a href="https://img.shields.io/badge/License-MIT-yellow.svg"><img
10
+ src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
11
+ </div>
8
12
 
9
- ![MCP Computer Control Demo](demonstration.gif)
13
+ ---
14
+
15
+ ![MCP Computer Control Demo](https://github.com/AB498/computer-control-mcp/blob/main/demonstration.gif?raw=true)
10
16
 
11
17
  ## Quick Usage (MCP Setup Using `uvx`)
12
18
 
19
+ ***Note:** Running `uvx computer-control-mcp@latest` for the first time will download python dependencies (around 70MB) which may take some time. Recommended to run this in a terminal before using it as MCP. Subsequent runs will be instant.*
20
+
13
21
  ```json
14
22
  {
15
23
  "mcpServers": {
@@ -57,14 +65,6 @@ computer-control-mcp # instead of uvx computer-control-mcp, so you can use the l
57
65
  - `list_windows()`: List all open windows
58
66
  - `activate_window(title_pattern: str, use_regex: bool = False, threshold: int = 60)`: Bring specified window to foreground
59
67
 
60
- ### Running as a Module
61
-
62
- You can run the package as a module:
63
-
64
- ```bash
65
- python -m computer_control_mcp
66
- ```
67
-
68
68
  ## Development
69
69
 
70
70
  ### Setting up the Development Environment
@@ -76,6 +76,9 @@ cd computer-control-mcp
76
76
 
77
77
  # Install in development mode
78
78
  pip install -e .
79
+
80
+ # Start server
81
+ python -m computer_control_mcp.core
79
82
  ```
80
83
 
81
84
  ### Running Tests
@@ -91,3 +94,8 @@ See the [API Reference](docs/api.md) for detailed information about the availabl
91
94
  ## License
92
95
 
93
96
  MIT
97
+
98
+ ## For more information or help
99
+
100
+ - [Email (abcd49800@gmail.com)](mailto:abcd49800@gmail.com)
101
+ - [Discord (CodePlayground)](https://discord.gg/ZeeqSBpjU2)
@@ -1,456 +1,467 @@
1
- #!/usr/bin/env python3
2
- """
3
- Computer Control MCP - Core Implementation
4
- A compact ModelContextProtocol server that provides computer control capabilities
5
- using PyAutoGUI for mouse/keyboard control.
6
- """
7
-
8
- import shutil
9
- import sys
10
- import os
11
- from typing import Dict, Any, List, Optional, Tuple
12
- from io import BytesIO
13
- import re
14
- import asyncio
15
- import uuid
16
- import datetime
17
- from pathlib import Path
18
- import tempfile
19
-
20
- # --- Auto-install dependencies if needed ---
21
- import pyautogui
22
- from mcp.server.fastmcp import FastMCP, Image
23
- import pygetwindow as gw
24
- from fuzzywuzzy import fuzz, process
25
-
26
- from rapidocr import RapidOCR
27
- import cv2
28
- from rapidocr_onnxruntime import RapidOCR, VisRes
29
-
30
-
31
- DEBUG = True # Set to False in production
32
- RELOAD_ENABLED = True # Set to False to disable auto-reload
33
-
34
- # Create FastMCP server instance at module level
35
- mcp = FastMCP("ComputerControlMCP", version="1.0.0")
36
-
37
-
38
- def log(message: str) -> None:
39
- """Log a message to stderr."""
40
- print(f"STDOUT: {message}", file=sys.stderr)
41
-
42
-
43
- def get_downloads_dir() -> Path:
44
- """Get the OS downloads directory."""
45
- if os.name == "nt": # Windows
46
- import winreg
47
-
48
- sub_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
49
- downloads_guid = "{374DE290-123F-4565-9164-39C4925E467B}"
50
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, sub_key) as key:
51
- downloads_dir = winreg.QueryValueEx(key, downloads_guid)[0]
52
- return Path(downloads_dir)
53
- else: # macOS, Linux, etc.
54
- return Path.home() / "Downloads"
55
-
56
-
57
- def save_image_to_downloads(
58
- image, prefix: str = "screenshot", directory: Path = None
59
- ) -> Tuple[str, bytes]:
60
- """Save an image to the downloads directory and return its absolute path.
61
-
62
- Args:
63
- image: Either a PIL Image object or MCP Image object
64
- prefix: Prefix for the filename (default: 'screenshot')
65
- directory: Optional directory to save the image to
66
-
67
- Returns:
68
- Tuple of (absolute_path, image_data_bytes)
69
- """
70
- # Create a unique filename with timestamp
71
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
72
- unique_id = str(uuid.uuid4())[:8]
73
- filename = f"{prefix}_{timestamp}_{unique_id}.png"
74
-
75
- # Get downloads directory
76
- downloads_dir = directory or get_downloads_dir()
77
- filepath = downloads_dir / filename
78
-
79
- # Handle different image types
80
- if hasattr(image, "save"): # PIL Image
81
- image.save(filepath)
82
- # Also get the bytes for returning
83
- img_byte_arr = BytesIO()
84
- image.save(img_byte_arr, format="PNG")
85
- img_bytes = img_byte_arr.getvalue()
86
- elif hasattr(image, "data"): # MCP Image
87
- img_bytes = image.data
88
- with open(filepath, "wb") as f:
89
- f.write(img_bytes)
90
- else:
91
- raise TypeError("Unsupported image type")
92
-
93
- log(f"Saved image to {filepath}")
94
- return str(filepath.absolute()), img_bytes
95
-
96
-
97
- def _find_matching_window(
98
- windows: any,
99
- title_pattern: str = None,
100
- use_regex: bool = False,
101
- threshold: int = 60,
102
- ) -> Optional[Dict[str, Any]]:
103
- """Helper function to find a matching window based on title pattern.
104
-
105
- Args:
106
- windows: List of window dictionaries
107
- title_pattern: Pattern to match window title
108
- use_regex: If True, treat the pattern as a regex, otherwise use fuzzy matching
109
- threshold: Minimum score (0-100) required for a fuzzy match
110
-
111
- Returns:
112
- The best matching window or None if no match found
113
- """
114
- if not title_pattern:
115
- log("No title pattern provided, returning None")
116
- return None
117
-
118
- # For regex matching
119
- if use_regex:
120
- for window in windows:
121
- if re.search(title_pattern, window["title"], re.IGNORECASE):
122
- log(f"Regex match found: {window['title']}")
123
- return window
124
- return None
125
-
126
- # For fuzzy matching using fuzzywuzzy
127
- # Extract all window titles
128
- window_titles = [window["title"] for window in windows]
129
-
130
- # Use process.extractOne to find the best match
131
- best_match_title, score = process.extractOne(
132
- title_pattern, window_titles, scorer=fuzz.partial_ratio
133
- )
134
- log(f"Best fuzzy match: '{best_match_title}' with score {score}")
135
-
136
- # Only return if the score is above the threshold
137
- if score >= threshold:
138
- # Find the window with the matching title
139
- for window in windows:
140
- if window["title"] == best_match_title:
141
- return window
142
-
143
- return None
144
-
145
-
146
- # --- MCP Function Handlers ---
147
-
148
-
149
- @mcp.tool()
150
- def tool_version() -> str:
151
- """Get the version of the tool."""
152
- return "0.2.7"
153
-
154
-
155
- @mcp.tool()
156
- def click_screen(x: int, y: int) -> str:
157
- """Click at the specified screen coordinates."""
158
- try:
159
- pyautogui.click(x=x, y=y)
160
- return f"Successfully clicked at coordinates ({x}, {y})"
161
- except Exception as e:
162
- return f"Error clicking at coordinates ({x}, {y}): {str(e)}"
163
-
164
-
165
- @mcp.tool()
166
- def get_screen_size() -> Dict[str, Any]:
167
- """Get the current screen resolution."""
168
- try:
169
- width, height = pyautogui.size()
170
- return {
171
- "width": width,
172
- "height": height,
173
- "message": f"Screen size: {width}x{height}",
174
- }
175
- except Exception as e:
176
- return {"error": str(e), "message": f"Error getting screen size: {str(e)}"}
177
-
178
-
179
- @mcp.tool()
180
- def type_text(text: str) -> str:
181
- """Type the specified text at the current cursor position."""
182
- try:
183
- pyautogui.typewrite(text)
184
- return f"Successfully typed text: {text}"
185
- except Exception as e:
186
- return f"Error typing text: {str(e)}"
187
-
188
-
189
- @mcp.tool()
190
- def take_screenshot(
191
- title_pattern: str = None,
192
- use_regex: bool = False,
193
- threshold: int = 60,
194
- with_ocr_text_and_coords: bool = False,
195
- scale_percent_for_ocr: int = 100,
196
- save_to_downloads: bool = False,
197
- ) -> Image | List[Tuple[List[List[int]], str, float]]:
198
- """
199
- Get screenshot and OCR text with absolute coordinates (returned after adding the window offset from true (0, 0) of screen to the OCR coordinates, so clicking is on-point. Recommended to click in the middle of OCR Box) and confidence from window with the specified title pattern.
200
- If no title pattern is provided, get screenshot of entire screen and all text on the screen.
201
-
202
- Args:
203
- title_pattern: Pattern to match window title, if None, take screenshot of entire screen
204
- use_regex: If True, treat the pattern as a regex, otherwise best match with fuzzy matching
205
- threshold: Minimum score (0-100) required for a fuzzy match
206
- with_ocr_text_and_coords: If True, get OCR text with absolute coordinates from the screenshot
207
- scale_percent_for_ocr: Percentage to scale the image down before processing, you wont need this most of the time unless your pc is extremely old or slow
208
- save_to_downloads: If True, save the screenshot to the downloads directory and return the absolute path
209
-
210
- Returns:
211
- Returns a single screenshot as MCP Image object, if with_ocr_text_and_coords is True, returns a MCP Image object followed by list of UI elements as [[4 corners of box], text, confidence], "content type image not supported" means preview isnt supported but Image object is there.
212
- """
213
- try:
214
-
215
- all_windows = gw.getAllWindows()
216
-
217
- # Convert to list of dictionaries for _find_matching_window
218
- windows = []
219
- for window in all_windows:
220
- if window.title: # Only include windows with titles
221
- windows.append(
222
- {
223
- "title": window.title,
224
- "window_obj": window, # Store the actual window object
225
- }
226
- )
227
-
228
- log(f"Found {len(windows)} windows")
229
- window = _find_matching_window(windows, title_pattern, use_regex, threshold)
230
- window = window["window_obj"] if window else None
231
-
232
- # Store the currently active window
233
-
234
- # Take the screenshot
235
- if not window:
236
- log("No matching window found, taking screenshot of entire screen")
237
- screenshot = pyautogui.screenshot()
238
- else:
239
- current_active_window = gw.getActiveWindow()
240
- log(f"Taking screenshot of window: {window.title}")
241
- # Activate the window and wait for it to be fully in focus
242
- window.activate()
243
- pyautogui.sleep(0.5) # Wait for 0.5 seconds to ensure window is active
244
- screenshot = pyautogui.screenshot(
245
- region=(window.left, window.top, window.width, window.height)
246
- )
247
- # Restore the previously active window
248
- if current_active_window:
249
- try:
250
- current_active_window.activate()
251
- pyautogui.sleep(0.2) # Wait a bit to ensure previous window is restored
252
- except Exception as e:
253
- log(f"Error restoring previous window: {str(e)}")
254
-
255
- # Create temp directory
256
- temp_dir = Path(tempfile.mkdtemp())
257
-
258
- # Save screenshot and get filepath
259
- filepath, _ = save_image_to_downloads(
260
- screenshot, prefix="screenshot", directory=temp_dir
261
- )
262
-
263
- # Create Image object from filepath
264
- image = Image(filepath)
265
-
266
- if not with_ocr_text_and_coords:
267
- return image # MCP Image object
268
-
269
- # Copy from temp to downloads
270
- if save_to_downloads:
271
- log("Copying screenshot from temp to downloads")
272
- shutil.copy(filepath, get_downloads_dir())
273
-
274
- image_path = image.path
275
- img = cv2.imread(image_path)
276
-
277
- # Lower down resolution before processing
278
- width = int(img.shape[1] * scale_percent_for_ocr / 100)
279
- height = int(img.shape[0] * scale_percent_for_ocr / 100)
280
- dim = (width, height)
281
- resized_img = cv2.resize(img, dim, interpolation=cv2.INTER_AREA)
282
- # save resized image to pwd
283
- # cv2.imwrite("resized_img.png", resized_img)
284
- engine = RapidOCR()
285
- vis = VisRes()
286
-
287
- result, elapse_list = engine(resized_img)
288
- boxes, txts, scores = list(zip(*result))
289
- boxes = [
290
- [[x + window.left, y + window.top] if window else [x, y] for x, y in box]
291
- for box in boxes
292
- ]
293
- zipped_results = list(zip(boxes, txts, scores))
294
-
295
- return [image, *zipped_results]
296
-
297
- except Exception as e:
298
- log(f"Error in screenshot or getting UI elements: {str(e)}")
299
- import traceback
300
-
301
- stack_trace = traceback.format_exc()
302
- log(f"Stack trace:\n{stack_trace}")
303
- return f"Error in screenshot or getting UI elements: {str(e)}\nStack trace:\n{stack_trace}"
304
-
305
-
306
- @mcp.tool()
307
- def move_mouse(x: int, y: int) -> str:
308
- """Move the mouse to the specified screen coordinates."""
309
- try:
310
- pyautogui.moveTo(x=x, y=y)
311
- return f"Successfully moved mouse to coordinates ({x}, {y})"
312
- except Exception as e:
313
- return f"Error moving mouse to coordinates ({x}, {y}): {str(e)}"
314
-
315
-
316
- @mcp.tool()
317
- async def drag_mouse(
318
- from_x: int, from_y: int, to_x: int, to_y: int, duration: float = 0.5
319
- ) -> str:
320
- """
321
- Drag the mouse from one position to another.
322
-
323
- Args:
324
- from_x: Starting X coordinate
325
- from_y: Starting Y coordinate
326
- to_x: Ending X coordinate
327
- to_y: Ending Y coordinate
328
- duration: Duration of the drag in seconds (default: 0.5)
329
-
330
- Returns:
331
- Success or error message
332
- """
333
- try:
334
- # First move to the starting position
335
- pyautogui.moveTo(x=from_x, y=from_y)
336
- # Then drag to the destination
337
- log("starting drag")
338
- await asyncio.to_thread(pyautogui.dragTo, x=to_x, y=to_y, duration=duration)
339
- log("done drag")
340
- return f"Successfully dragged from ({from_x}, {from_y}) to ({to_x}, {to_y})"
341
- except Exception as e:
342
- return f"Error dragging from ({from_x}, {from_y}) to ({to_x}, {to_y}): {str(e)}"
343
-
344
-
345
- @mcp.tool()
346
- def press_key(key: str) -> str:
347
- """Press the specified keyboard key."""
348
- try:
349
- pyautogui.press(key)
350
- return f"Successfully pressed key: {key}"
351
- except Exception as e:
352
- return f"Error pressing key {key}: {str(e)}"
353
-
354
-
355
- @mcp.tool()
356
- def list_windows() -> List[Dict[str, Any]]:
357
- """List all open windows on the system."""
358
- try:
359
- windows = gw.getAllWindows()
360
- result = []
361
- for window in windows:
362
- if window.title: # Only include windows with titles
363
- result.append(
364
- {
365
- "title": window.title,
366
- "left": window.left,
367
- "top": window.top,
368
- "width": window.width,
369
- "height": window.height,
370
- "is_active": window.isActive,
371
- "is_visible": window.visible,
372
- "is_minimized": window.isMinimized,
373
- "is_maximized": window.isMaximized,
374
- "screenshot": pyautogui.screenshot(
375
- region=(
376
- window.left,
377
- window.top,
378
- window.width,
379
- window.height,
380
- )
381
- ),
382
- }
383
- )
384
- return result
385
- except Exception as e:
386
- log(f"Error listing windows: {str(e)}")
387
- return [{"error": str(e)}]
388
-
389
-
390
- @mcp.tool()
391
- def activate_window(
392
- title_pattern: str, use_regex: bool = False, threshold: int = 60
393
- ) -> str:
394
- """
395
- Activate a window (bring it to the foreground) by matching its title.
396
-
397
- Args:
398
- title_pattern: Pattern to match window title
399
- use_regex: If True, treat the pattern as a regex, otherwise use fuzzy matching
400
- threshold: Minimum score (0-100) required for a fuzzy match
401
-
402
- Returns:
403
- Success or error message
404
- """
405
- try:
406
- # Get all windows
407
- all_windows = gw.getAllWindows()
408
-
409
- # Convert to list of dictionaries for _find_matching_window
410
- windows = []
411
- for window in all_windows:
412
- if window.title: # Only include windows with titles
413
- windows.append(
414
- {
415
- "title": window.title,
416
- "window_obj": window, # Store the actual window object
417
- }
418
- )
419
-
420
- # Find matching window using our improved function
421
- matched_window_dict = _find_matching_window(
422
- windows, title_pattern, use_regex, threshold
423
- )
424
-
425
- if not matched_window_dict:
426
- log(f"No window found matching pattern: {title_pattern}")
427
- return f"Error: No window found matching pattern: {title_pattern}"
428
-
429
- # Get the actual window object
430
- matched_window = matched_window_dict["window_obj"]
431
-
432
- # Activate the window
433
- matched_window.activate()
434
-
435
- return f"Successfully activated window: '{matched_window.title}'"
436
- except Exception as e:
437
- log(f"Error activating window: {str(e)}")
438
- return f"Error activating window: {str(e)}"
439
-
440
-
441
- def main():
442
- """Main entry point for the MCP server."""
443
- pyautogui.FAILSAFE = True
444
-
445
- try:
446
- # Run the server
447
- mcp.run()
448
-
449
- except KeyboardInterrupt:
450
- log("Server shutting down...")
451
- except Exception as e:
452
- log(f"Error: {str(e)}")
453
-
454
-
455
- if __name__ == "__main__":
456
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ Computer Control MCP - Core Implementation
4
+ A compact ModelContextProtocol server that provides computer control capabilities
5
+ using PyAutoGUI for mouse/keyboard control.
6
+ """
7
+
8
+ import json
9
+ import shutil
10
+ import sys
11
+ import os
12
+ from typing import Dict, Any, List, Optional, Tuple
13
+ from io import BytesIO
14
+ import re
15
+ import asyncio
16
+ import uuid
17
+ import datetime
18
+ from pathlib import Path
19
+ import tempfile
20
+
21
+ # --- Auto-install dependencies if needed ---
22
+ import pyautogui
23
+ from mcp.server.fastmcp import FastMCP, Image
24
+ import pygetwindow as gw
25
+ from fuzzywuzzy import fuzz, process
26
+
27
+ import cv2
28
+ from rapidocr import RapidOCR
29
+ from rapidocr_onnxruntime import VisRes
30
+
31
+ from pydantic import BaseModel
32
+
33
+ BaseModel.model_config = {'arbitrary_types_allowed': True}
34
+
35
+ engine = RapidOCR()
36
+ vis = VisRes()
37
+
38
+
39
+ DEBUG = True # Set to False in production
40
+ RELOAD_ENABLED = True # Set to False to disable auto-reload
41
+
42
+ # Create FastMCP server instance at module level
43
+ mcp = FastMCP("ComputerControlMCP")
44
+
45
+
46
+ def log(message: str) -> None:
47
+ """Log a message to stderr."""
48
+ print(f"STDOUT: {message}", file=sys.stderr)
49
+
50
+
51
+ def get_downloads_dir() -> Path:
52
+ """Get the OS downloads directory."""
53
+ if os.name == "nt": # Windows
54
+ import winreg
55
+
56
+ sub_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
57
+ downloads_guid = "{374DE290-123F-4565-9164-39C4925E467B}"
58
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, sub_key) as key:
59
+ downloads_dir = winreg.QueryValueEx(key, downloads_guid)[0]
60
+ return Path(downloads_dir)
61
+ else: # macOS, Linux, etc.
62
+ return Path.home() / "Downloads"
63
+
64
+
65
+ def save_image_to_downloads(
66
+ image, prefix: str = "screenshot", directory: Path = None
67
+ ) -> Tuple[str, bytes]:
68
+ """Save an image to the downloads directory and return its absolute path.
69
+
70
+ Args:
71
+ image: Either a PIL Image object or MCP Image object
72
+ prefix: Prefix for the filename (default: 'screenshot')
73
+ directory: Optional directory to save the image to
74
+
75
+ Returns:
76
+ Tuple of (absolute_path, image_data_bytes)
77
+ """
78
+ # Create a unique filename with timestamp
79
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
80
+ unique_id = str(uuid.uuid4())[:8]
81
+ filename = f"{prefix}_{timestamp}_{unique_id}.png"
82
+
83
+ # Get downloads directory
84
+ downloads_dir = directory or get_downloads_dir()
85
+ filepath = downloads_dir / filename
86
+
87
+ # Handle different image types
88
+ if hasattr(image, "save"): # PIL Image
89
+ image.save(filepath)
90
+ # Also get the bytes for returning
91
+ img_byte_arr = BytesIO()
92
+ image.save(img_byte_arr, format="PNG")
93
+ img_bytes = img_byte_arr.getvalue()
94
+ elif hasattr(image, "data"): # MCP Image
95
+ img_bytes = image.data
96
+ with open(filepath, "wb") as f:
97
+ f.write(img_bytes)
98
+ else:
99
+ raise TypeError("Unsupported image type")
100
+
101
+ log(f"Saved image to {filepath}")
102
+ return str(filepath.absolute()), img_bytes
103
+
104
+
105
+ def _find_matching_window(
106
+ windows: any,
107
+ title_pattern: str = None,
108
+ use_regex: bool = False,
109
+ threshold: int = 60,
110
+ ) -> Optional[Dict[str, Any]]:
111
+ """Helper function to find a matching window based on title pattern.
112
+
113
+ Args:
114
+ windows: List of window dictionaries
115
+ title_pattern: Pattern to match window title
116
+ use_regex: If True, treat the pattern as a regex, otherwise use fuzzy matching
117
+ threshold: Minimum score (0-100) required for a fuzzy match
118
+
119
+ Returns:
120
+ The best matching window or None if no match found
121
+ """
122
+ if not title_pattern:
123
+ log("No title pattern provided, returning None")
124
+ return None
125
+
126
+ # For regex matching
127
+ if use_regex:
128
+ for window in windows:
129
+ if re.search(title_pattern, window["title"], re.IGNORECASE):
130
+ log(f"Regex match found: {window['title']}")
131
+ return window
132
+ return None
133
+
134
+ # For fuzzy matching using fuzzywuzzy
135
+ # Extract all window titles
136
+ window_titles = [window["title"] for window in windows]
137
+
138
+ # Use process.extractOne to find the best match
139
+ best_match_title, score = process.extractOne(
140
+ title_pattern, window_titles, scorer=fuzz.partial_ratio
141
+ )
142
+ log(f"Best fuzzy match: '{best_match_title}' with score {score}")
143
+
144
+ # Only return if the score is above the threshold
145
+ if score >= threshold:
146
+ # Find the window with the matching title
147
+ for window in windows:
148
+ if window["title"] == best_match_title:
149
+ return window
150
+
151
+ return None
152
+
153
+
154
+ # --- MCP Function Handlers ---
155
+
156
+ @mcp.tool()
157
+ def click_screen(x: int, y: int) -> str:
158
+ """Click at the specified screen coordinates."""
159
+ try:
160
+ pyautogui.click(x=x, y=y)
161
+ return f"Successfully clicked at coordinates ({x}, {y})"
162
+ except Exception as e:
163
+ return f"Error clicking at coordinates ({x}, {y}): {str(e)}"
164
+
165
+
166
+ @mcp.tool()
167
+ def get_screen_size() -> Dict[str, Any]:
168
+ """Get the current screen resolution."""
169
+ try:
170
+ width, height = pyautogui.size()
171
+ return {
172
+ "width": width,
173
+ "height": height,
174
+ "message": f"Screen size: {width}x{height}",
175
+ }
176
+ except Exception as e:
177
+ return {"error": str(e), "message": f"Error getting screen size: {str(e)}"}
178
+
179
+
180
+ @mcp.tool()
181
+ def type_text(text: str) -> str:
182
+ """Type the specified text at the current cursor position."""
183
+ try:
184
+ pyautogui.typewrite(text)
185
+ return f"Successfully typed text: {text}"
186
+ except Exception as e:
187
+ return f"Error typing text: {str(e)}"
188
+
189
+
190
+ @mcp.tool()
191
+ def take_screenshot(
192
+ title_pattern: str = None,
193
+ use_regex: bool = False,
194
+ threshold: int = 60,
195
+ with_ocr_text_and_coords: bool = False,
196
+ scale_percent_for_ocr: int = None,
197
+ save_to_downloads: bool = False,
198
+ ) -> Image | List[Tuple[List[List[int]], str, float]]:
199
+ """
200
+ Get screenshot and OCR text with absolute coordinates (returned after adding the window offset from true (0, 0) of screen to the OCR coordinates, so clicking is on-point. Recommended to click in the middle of OCR Box) and confidence from window with the specified title pattern.
201
+ If no title pattern is provided, get screenshot of entire screen and all text on the screen.
202
+
203
+ Args:
204
+ title_pattern: Pattern to match window title, if None, take screenshot of entire screen
205
+ use_regex: If True, treat the pattern as a regex, otherwise best match with fuzzy matching
206
+ threshold: Minimum score (0-100) required for a fuzzy match
207
+ with_ocr_text_and_coords: If True, get OCR text with absolute coordinates from the screenshot
208
+ scale_percent_for_ocr: Percentage to scale the image down before processing, you wont need this most of the time unless your pc is extremely old or slow
209
+ save_to_downloads: If True, save the screenshot to the downloads directory and return the absolute path
210
+
211
+ Returns:
212
+ Returns a single screenshot as MCP Image object, if with_ocr_text_and_coords is True, returns a MCP Image object followed by list of UI elements as [[4 corners of box], text, confidence], "content type image not supported" means preview isnt supported but Image object is there.
213
+ """
214
+ try:
215
+
216
+ all_windows = gw.getAllWindows()
217
+
218
+ # Convert to list of dictionaries for _find_matching_window
219
+ windows = []
220
+ for window in all_windows:
221
+ if window.title: # Only include windows with titles
222
+ windows.append(
223
+ {
224
+ "title": window.title,
225
+ "window_obj": window, # Store the actual window object
226
+ }
227
+ )
228
+
229
+ log(f"Found {len(windows)} windows")
230
+ window = _find_matching_window(windows, title_pattern, use_regex, threshold)
231
+ window = window["window_obj"] if window else None
232
+
233
+ # Store the currently active window
234
+
235
+ # Take the screenshot
236
+ if not window:
237
+ log("No matching window found, taking screenshot of entire screen")
238
+ screenshot = pyautogui.screenshot()
239
+ else:
240
+ current_active_window = gw.getActiveWindow()
241
+ log(f"Taking screenshot of window: {window.title}")
242
+ # Activate the window and wait for it to be fully in focus
243
+ try:
244
+
245
+ window.activate()
246
+ pyautogui.sleep(0.5) # Wait for 0.5 seconds to ensure window is active
247
+ screenshot = pyautogui.screenshot(
248
+ region=(window.left, window.top, window.width, window.height)
249
+ )
250
+ # Restore the previously active window
251
+ if current_active_window:
252
+ try:
253
+ current_active_window.activate()
254
+ pyautogui.sleep(0.2) # Wait a bit to ensure previous window is restored
255
+ except Exception as e:
256
+ log(f"Error restoring previous window: {str(e)}")
257
+ except Exception as e:
258
+ log(f"Error taking screenshot of window: {str(e)}")
259
+ return f"Error taking screenshot of window: {str(e)}"
260
+
261
+ # Create temp directory
262
+ temp_dir = Path(tempfile.mkdtemp())
263
+
264
+ # Save screenshot and get filepath
265
+ filepath, _ = save_image_to_downloads(
266
+ screenshot, prefix="screenshot", directory=temp_dir
267
+ )
268
+
269
+ # Create Image object from filepath
270
+ image = Image(filepath)
271
+
272
+ if not with_ocr_text_and_coords:
273
+ return image # MCP Image object
274
+
275
+ # Copy from temp to downloads
276
+ if save_to_downloads:
277
+ log("Copying screenshot from temp to downloads")
278
+ shutil.copy(filepath, get_downloads_dir())
279
+
280
+ image_path = image.path
281
+ img = cv2.imread(image_path)
282
+
283
+ if scale_percent_for_ocr is None:
284
+ # Calculate percent to scale height to 360 pixels
285
+ scale_percent_for_ocr = 100 # 360 / img.shape[0] * 100
286
+
287
+ # Lower down resolution before processing
288
+ width = int(img.shape[1] * scale_percent_for_ocr / 100)
289
+ height = int(img.shape[0] * scale_percent_for_ocr / 100)
290
+ dim = (width, height)
291
+ resized_img = cv2.resize(img, dim, interpolation=cv2.INTER_AREA)
292
+ # save resized image to pwd
293
+ # cv2.imwrite("resized_img.png", resized_img)
294
+
295
+ output = engine(resized_img)
296
+ boxes = output.boxes
297
+ txts = output.txts
298
+ scores = output.scores
299
+ zipped_results = list(zip(boxes, txts, scores))
300
+ zipped_results = [
301
+ (box.tolist(), text, float(score)) # convert np.array -> list, ensure score is float
302
+ for box, text, score in zipped_results
303
+ ]
304
+ log(f"Found {len(zipped_results)} text items in OCR result.")
305
+ log(f"First 5 items: {zipped_results[:5]}")
306
+ return json.dumps(zipped_results)
307
+ # ...existing code...
308
+ except Exception as e:
309
+ log(f"Error in screenshot or getting UI elements: {str(e)}")
310
+ import traceback
311
+
312
+ stack_trace = traceback.format_exc()
313
+ log(f"Stack trace:\n{stack_trace}")
314
+ return f"Error in screenshot or getting UI elements: {str(e)}\nStack trace:\n{stack_trace}"
315
+
316
+
317
+ @mcp.tool()
318
+ def move_mouse(x: int, y: int) -> str:
319
+ """Move the mouse to the specified screen coordinates."""
320
+ try:
321
+ pyautogui.moveTo(x=x, y=y)
322
+ return f"Successfully moved mouse to coordinates ({x}, {y})"
323
+ except Exception as e:
324
+ return f"Error moving mouse to coordinates ({x}, {y}): {str(e)}"
325
+
326
+
327
+ @mcp.tool()
328
+ async def drag_mouse(
329
+ from_x: int, from_y: int, to_x: int, to_y: int, duration: float = 0.5
330
+ ) -> str:
331
+ """
332
+ Drag the mouse from one position to another.
333
+
334
+ Args:
335
+ from_x: Starting X coordinate
336
+ from_y: Starting Y coordinate
337
+ to_x: Ending X coordinate
338
+ to_y: Ending Y coordinate
339
+ duration: Duration of the drag in seconds (default: 0.5)
340
+
341
+ Returns:
342
+ Success or error message
343
+ """
344
+ try:
345
+ # First move to the starting position
346
+ pyautogui.moveTo(x=from_x, y=from_y)
347
+ # Then drag to the destination
348
+ log("starting drag")
349
+ await asyncio.to_thread(pyautogui.dragTo, x=to_x, y=to_y, duration=duration)
350
+ log("done drag")
351
+ return f"Successfully dragged from ({from_x}, {from_y}) to ({to_x}, {to_y})"
352
+ except Exception as e:
353
+ return f"Error dragging from ({from_x}, {from_y}) to ({to_x}, {to_y}): {str(e)}"
354
+
355
+
356
+ @mcp.tool()
357
+ def press_key(key: str) -> str:
358
+ """Press the specified keyboard key."""
359
+ try:
360
+ pyautogui.press(key)
361
+ return f"Successfully pressed key: {key}"
362
+ except Exception as e:
363
+ return f"Error pressing key {key}: {str(e)}"
364
+
365
+
366
+ @mcp.tool()
367
+ def list_windows() -> List[Dict[str, Any]]:
368
+ """List all open windows on the system."""
369
+ try:
370
+ windows = gw.getAllWindows()
371
+ result = []
372
+ for window in windows:
373
+ if window.title: # Only include windows with titles
374
+ result.append(
375
+ {
376
+ "title": window.title,
377
+ "left": window.left,
378
+ "top": window.top,
379
+ "width": window.width,
380
+ "height": window.height,
381
+ "is_active": window.isActive,
382
+ "is_visible": window.visible,
383
+ "is_minimized": window.isMinimized,
384
+ "is_maximized": window.isMaximized,
385
+ # "screenshot": pyautogui.screenshot(
386
+ # region=(
387
+ # window.left,
388
+ # window.top,
389
+ # window.width,
390
+ # window.height,
391
+ # )
392
+ # ),
393
+ }
394
+ )
395
+ return result
396
+ except Exception as e:
397
+ log(f"Error listing windows: {str(e)}")
398
+ return [{"error": str(e)}]
399
+
400
+
401
+ @mcp.tool()
402
+ def activate_window(
403
+ title_pattern: str, use_regex: bool = False, threshold: int = 60
404
+ ) -> str:
405
+ """
406
+ Activate a window (bring it to the foreground) by matching its title.
407
+
408
+ Args:
409
+ title_pattern: Pattern to match window title
410
+ use_regex: If True, treat the pattern as a regex, otherwise use fuzzy matching
411
+ threshold: Minimum score (0-100) required for a fuzzy match
412
+
413
+ Returns:
414
+ Success or error message
415
+ """
416
+ try:
417
+ # Get all windows
418
+ all_windows = gw.getAllWindows()
419
+
420
+ # Convert to list of dictionaries for _find_matching_window
421
+ windows = []
422
+ for window in all_windows:
423
+ if window.title: # Only include windows with titles
424
+ windows.append(
425
+ {
426
+ "title": window.title,
427
+ "window_obj": window, # Store the actual window object
428
+ }
429
+ )
430
+
431
+ # Find matching window using our improved function
432
+ matched_window_dict = _find_matching_window(
433
+ windows, title_pattern, use_regex, threshold
434
+ )
435
+
436
+ if not matched_window_dict:
437
+ log(f"No window found matching pattern: {title_pattern}")
438
+ return f"Error: No window found matching pattern: {title_pattern}"
439
+
440
+ # Get the actual window object
441
+ matched_window = matched_window_dict["window_obj"]
442
+
443
+ # Activate the window
444
+ matched_window.activate()
445
+
446
+ return f"Successfully activated window: '{matched_window.title}'"
447
+ except Exception as e:
448
+ log(f"Error activating window: {str(e)}")
449
+ return f"Error activating window: {str(e)}"
450
+
451
+
452
+ def main():
453
+ """Main entry point for the MCP server."""
454
+ pyautogui.FAILSAFE = True
455
+
456
+ try:
457
+ # Run the server
458
+ mcp.run()
459
+
460
+ except KeyboardInterrupt:
461
+ log("Server shutting down...")
462
+ except Exception as e:
463
+ log(f"Error: {str(e)}")
464
+
465
+
466
+ if __name__ == "__main__":
467
+ main()
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "computer-control-mcp"
7
- version = "0.2.7"
8
- description = "Computer control capabilities using PyAutoGUI through a Model Context Protocol (MCP) server"
7
+ version = "0.2.8"
8
+ description = "MCP server that provides computer control capabilities, like mouse, keyboard, OCR, etc. using PyAutoGUI, RapidOCR, ONNXRuntime. Similar to 'computer-use' by Anthropic. With Zero External Dependencies."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
11
11
  license = {text = "MIT"}
12
12
  authors = [
13
- {name = "Computer Control MCP Authors", email = "example@example.com"}
13
+ {name = "AB498", email = "abcd49800@gmail.com"},
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
@@ -22,15 +22,15 @@ classifiers = [
22
22
  "Topic :: Utilities"
23
23
  ]
24
24
  dependencies = [
25
- "pyautogui",
26
- "mcp[cli]",
27
- "pillow",
28
- "pygetwindow",
29
- "fuzzywuzzy",
30
- "rapidocr",
31
- "onnxruntime",
32
- "rapidocr_onnxruntime",
33
- "opencv-python"
25
+ "pyautogui==0.9.54",
26
+ "mcp[cli]==1.13.0",
27
+ "pillow==11.3.0",
28
+ "pygetwindow==0.0.9",
29
+ "fuzzywuzzy==0.18.0",
30
+ "rapidocr==3.3.1",
31
+ "onnxruntime==1.22.1",
32
+ "rapidocr_onnxruntime==1.4.4",
33
+ "opencv-python==4.12.0.88"
34
34
  ]
35
35
 
36
36
  [project.urls]
@@ -43,7 +43,7 @@ computer-control-mcp = "computer_control_mcp.cli:main"
43
43
  computer-control-mcp-server = "computer_control_mcp.server:main"
44
44
 
45
45
  [tool.hatch.build.targets.wheel]
46
- packages = ["computer_control_mcp"]
46
+ packages = ["src/computer_control_mcp"]
47
47
 
48
48
  [tool.pytest.ini_options]
49
49
  testpaths = ["tests"]