settled-computer 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,754 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ settled_computer.server - local (stdio) MCP server for desktop computer use with
4
+ event-driven settling.
5
+
6
+ Every action tool (click, type_text, press_key, scroll, drag, mouse_move) performs the action,
7
+ then waits until the screen reacts and stops changing (see settle.py), and returns the settled
8
+ screenshot plus a one-line note ("Screen settled 0.42s after the action" / "No visible change..."
9
+ / "Still changing after 8s"). No fixed sleeps, and no separate screenshot round trip.
10
+
11
+ The `act` tool runs a whole sequence (click -> type -> key) inside ONE tool call, validates every
12
+ step before touching the desktop, and stops at the first real anomaly, so the agent model spends
13
+ one turn on what used to be three.
14
+
15
+ Design rules worth knowing
16
+ --------------------------
17
+ * Tools are serialized with one lock. Hosts may issue parallel tool calls; without the lock their
18
+ settle windows overlap and each verdict is contaminated by the other action.
19
+ * Input (mouse/keyboard) runs on one dedicated worker thread, so a 3 s drag or a long paste never
20
+ blocks the event loop.
21
+ * screenshot() ALWAYS returns an image. Action results may omit the image ("no image = unchanged")
22
+ only while the last image the model received is younger than SETTLE_MCP_ELIDE_TTL seconds: the
23
+ server cannot know what is still in the model's context (new chat, pruned images, compaction).
24
+ * A region that keeps animating (video, ticker) is auto-ignored after two consecutive bails, for
25
+ SETTLE_MCP_AUTO_IGNORE_SECS seconds, then re-checked, so it expires once the motion stops.
26
+ * Values set with configure() are pinned: adaptive learning and per-action defaults never override them.
27
+
28
+ Install
29
+ -------
30
+ pip install "mcp[cli]" mss pyautogui pillow numpy # + pyperclip for non-ASCII typing
31
+ pip install opencv-python # optional: ~3x faster JPEG encode
32
+ sudo apt install python3-tk # Linux only: pyautogui exits without it
33
+ python settle_mcp.py --check # verify capture + coordinates
34
+
35
+ Register (Claude Desktop: claude_desktop_config.json / Claude Code: `claude mcp add`)
36
+ -------------------------------------------------------------------------------------
37
+ {
38
+ "mcpServers": {
39
+ "settled-computer": {
40
+ "command": "python",
41
+ "args": ["/absolute/path/to/settle_mcp.py"]
42
+ }
43
+ }
44
+ }
45
+ claude mcp add settled-computer -- python /absolute/path/to/settle_mcp.py
46
+
47
+ Keep settle.py in the same folder. Coordinates are in the pixels of the image the tools return.
48
+
49
+ Environment variables
50
+ ---------------------
51
+ SETTLE_MCP_MONITOR monitor index (mss numbering, 1 = primary) default 1
52
+ SETTLE_MCP_MAX_WIDTH max width of images returned to the model in px default 1280
53
+ SETTLE_MCP_QUALITY JPEG quality of returned images default 70
54
+ SETTLE_MCP_FAILSAFE 0 disables pyautogui's fail-safe default 1
55
+ SETTLE_MCP_ELIDE_TTL seconds an unchanged screen may omit its image default 45 (0 = never omit)
56
+ SETTLE_MCP_AUTO_IGNORE_SECS seconds an auto-detected animating region is default 30 (0 = off)
57
+ ignored before it is re-checked
58
+
59
+ SAFETY: this server lets a model move your mouse and type on your real desktop. Keep the
60
+ pyautogui fail-safe on (slam the mouse into the top-left corner to abort all actions), and
61
+ prefer running it inside a VM or a dedicated user session. macOS needs Screen Recording and
62
+ Accessibility permission for the app hosting this process; Linux needs an X11 session
63
+ (Wayland blocks capture and synthetic input).
64
+
65
+ Never print to stdout in this file: stdout is the MCP transport. Logs go to stderr.
66
+ """
67
+ from __future__ import annotations
68
+
69
+ import argparse
70
+ import asyncio
71
+ import functools
72
+ import io
73
+ import os
74
+ import platform
75
+ import sys
76
+ import time
77
+ from concurrent.futures import ThreadPoolExecutor
78
+ from dataclasses import replace
79
+ from typing import Literal, Optional, Sequence
80
+
81
+ try: # pydantic requires typing_extensions.TypedDict on Python < 3.12
82
+ from typing_extensions import TypedDict
83
+ except ImportError:
84
+ from typing import TypedDict
85
+
86
+ import numpy as np
87
+
88
+ try:
89
+ import mss
90
+ import pyautogui
91
+ from PIL import Image as PILImage
92
+ except (Exception, SystemExit) as exc: # missing dependency, or no display (pyautogui exits without tkinter on Linux)
93
+ sys.stderr.write(
94
+ f"settle_mcp: cannot start ({exc!r}).\n"
95
+ "Install: pip install 'mcp[cli]' mss pyautogui pillow numpy (Linux also needs: sudo apt install python3-tk)\n"
96
+ "and run inside a desktop session.\n"
97
+ )
98
+ raise SystemExit(1)
99
+
100
+ try: # optional, ~3x faster than PIL for BGRA -> resized JPEG
101
+ import cv2
102
+ except ImportError:
103
+ cv2 = None
104
+
105
+ from pydantic import ConfigDict
106
+
107
+ try: # mcp >= 2.0 renamed FastMCP to MCPServer
108
+ from mcp.server.mcpserver import Image, MCPServer as _Server
109
+ from mcp.server.mcpserver.exceptions import ToolError
110
+ except ImportError: # mcp 1.x
111
+ from mcp.server.fastmcp import FastMCP as _Server, Image
112
+ from mcp.server.fastmcp.exceptions import ToolError
113
+
114
+ from .engine import LatencyBook, SettleConfig, SettleResult, _as_u32, act_and_settle, wait_settled
115
+
116
+ MONITOR = int(os.environ.get("SETTLE_MCP_MONITOR", "1"))
117
+ MAX_WIDTH = int(os.environ.get("SETTLE_MCP_MAX_WIDTH", "1280"))
118
+ JPEG_QUALITY = int(os.environ.get("SETTLE_MCP_QUALITY", "70"))
119
+ ELIDE_TTL = float(os.environ.get("SETTLE_MCP_ELIDE_TTL", "45"))
120
+ AUTO_IGNORE_SECS = float(os.environ.get("SETTLE_MCP_AUTO_IGNORE_SECS", "30"))
121
+
122
+ pyautogui.PAUSE = 0.0 # pyautogui's default 0.1s pause after every call is itself a fixed sleep
123
+ pyautogui.FAILSAFE = os.environ.get("SETTLE_MCP_FAILSAFE", "1") != "0"
124
+
125
+ _IS_MAC = platform.system() == "Darwin"
126
+
127
+ # Per-action defaults. They apply only to fields the user has NOT pinned through configure().
128
+ _KIND_DEFAULTS = {
129
+ "type": {"react_deadline": 0.2, "quiet_time": 0.12},
130
+ "scroll": {"quiet_time": 0.15},
131
+ "hover": {"react_deadline": 0.2},
132
+ }
133
+
134
+
135
+ def log(msg: str) -> None:
136
+ sys.stderr.write(f"settle_mcp: {msg}\n")
137
+ sys.stderr.flush()
138
+
139
+
140
+ # --------------------------------------------------------------------------- tool allowlist
141
+ def _tool_enabled(name: str) -> bool:
142
+ """SETTLE_MCP_TOOLS=a,b,c allows only the named tools. Unset = all enabled.
143
+ lets an operator strip input-injection (e.g. `SETTLE_MCP_TOOLS=screenshot,screen_info,wait`
144
+ for a look-but-don't-touch server) — disabled tools fail with a clear error."""
145
+ raw = os.environ.get("SETTLE_MCP_TOOLS")
146
+ if not raw:
147
+ return True
148
+ return name in {t.strip() for t in raw.split(",") if t.strip()}
149
+
150
+
151
+ def gated(name: str):
152
+ """Decorator for mutating tools: refuse the call when the allowlist excludes it."""
153
+ import functools
154
+
155
+ def deco(fn):
156
+ @functools.wraps(fn)
157
+ async def wrapper(*args, **kwargs):
158
+ if not _tool_enabled(name):
159
+ raise ToolError(
160
+ f"tool {name!r} is disabled on this server by the SETTLE_MCP_TOOLS allowlist")
161
+ return await fn(*args, **kwargs)
162
+ return wrapper
163
+ return deco
164
+
165
+
166
+ # --------------------------------------------------------------------------- serialization
167
+ _LOCK: Optional[asyncio.Lock] = None
168
+ _INPUT_POOL = ThreadPoolExecutor(max_workers=1, thread_name_prefix="settle-input") # one stable input thread
169
+
170
+
171
+ def serialized(fn):
172
+ """Run tool calls one at a time. GUI actions are inherently sequential: overlapping settle windows
173
+ would attribute one action's effects to the other and interleave inputs."""
174
+ @functools.wraps(fn)
175
+ async def wrapper(*args, **kwargs):
176
+ global _LOCK
177
+ if _LOCK is None:
178
+ _LOCK = asyncio.Lock()
179
+ async with _LOCK:
180
+ return await fn(*args, **kwargs)
181
+ return wrapper
182
+
183
+
184
+ async def _run_input(fn) -> None:
185
+ """Execute blocking mouse/keyboard code off the event loop, on the dedicated input thread."""
186
+ loop = asyncio.get_running_loop()
187
+ try:
188
+ await loop.run_in_executor(_INPUT_POOL, fn)
189
+ except pyautogui.FailSafeException:
190
+ raise ToolError("pyautogui fail-safe triggered: the pointer is in a screen corner. "
191
+ "Move it away and retry (or set SETTLE_MCP_FAILSAFE=0).") from None
192
+
193
+
194
+ # --------------------------------------------------------------------------- capture + coordinates
195
+ def _make_grabber(monitor_index: int):
196
+ """mss capture bound to one monitor. Frames are BGRA uint8. Must be created and used on the
197
+ same thread (mss requirement on Windows), so it is created lazily inside the event loop."""
198
+ sct = (getattr(mss, "MSS", None) or mss.mss)() # mss.mss is deprecated in newer releases
199
+ mon = sct.monitors[monitor_index]
200
+
201
+ def grab() -> np.ndarray:
202
+ return np.asarray(sct.grab(mon)) # contiguous BGRA: enables settle.py's fast compare
203
+
204
+ return grab, mon
205
+
206
+
207
+ def _same_frame(a: Optional[np.ndarray], b: Optional[np.ndarray],
208
+ ignore_regions: Sequence[tuple] = ()) -> bool:
209
+ """Equal frames, optionally ignoring regions (screen fractions), e.g. a video that is always
210
+ animating and must not defeat the 'no image = unchanged' elision. Compares packed uint32
211
+ pixels and masks the difference instead of copying 8 MB frames."""
212
+ if a is None or b is None or a.shape != b.shape:
213
+ return False
214
+ ne = _as_u32(a) != _as_u32(b)
215
+ if not ne.any():
216
+ return True
217
+ h, w = ne.shape
218
+ for x0, y0, x1, y1 in ignore_regions:
219
+ ne[max(0, int(y0 * h) - 8):min(h, int(np.ceil(y1 * h)) + 8),
220
+ max(0, int(x0 * w) - 8):min(w, int(np.ceil(x1 * w)) + 8)] = False
221
+ return not ne.any()
222
+
223
+
224
+ def _overlap(a: tuple, b: tuple) -> float:
225
+ """Intersection area divided by the smaller box's area (0..1)."""
226
+ ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
227
+ iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
228
+ smaller = min((a[2] - a[0]) * (a[3] - a[1]), (b[2] - b[0]) * (b[3] - b[1]))
229
+ return (ix * iy) / smaller if smaller > 0 else 0.0
230
+
231
+
232
+ class State:
233
+ def __init__(self) -> None:
234
+ self.grab = None
235
+ self.mon: dict = {}
236
+ self.native_w = self.native_h = 0
237
+ self.img_w = self.img_h = 0
238
+ self.base = SettleConfig()
239
+ self.pinned: set = set() # fields set via configure(): never overridden
240
+ self.book = LatencyBook()
241
+ self.last_frame: Optional[np.ndarray] = None
242
+ self.last_sent: Optional[np.ndarray] = None # last frame the model actually received
243
+ self.last_sent_at = 0.0
244
+ self.auto_regions: dict = {} # box -> monotonic time it was (re)confirmed animating
245
+ self.pending_residual: Optional[tuple] = None
246
+
247
+ def ensure(self) -> None:
248
+ if self.grab is not None:
249
+ return
250
+ self.grab, self.mon = _make_grabber(MONITOR)
251
+ frame = self.grab()
252
+ self.native_h, self.native_w = frame.shape[:2]
253
+ scale = min(1.0, MAX_WIDTH / self.native_w)
254
+ self.img_w = max(1, round(self.native_w * scale))
255
+ self.img_h = max(1, round(self.native_h * scale))
256
+ self.last_frame = frame
257
+ log(f"monitor {MONITOR}: {self.mon}; native {self.native_w}x{self.native_h}; "
258
+ f"images {self.img_w}x{self.img_h}; jpeg encoder "
259
+ f"{'cv2' if cv2 is not None else 'PIL'}")
260
+
261
+ def to_point(self, x: float, y: float) -> tuple[int, int]:
262
+ """Image pixel -> global pyautogui coordinate (handles downscaling, HiDPI, monitor offset)."""
263
+ self.ensure() # image size is unknown until the first capture
264
+ if not (0 <= x < self.img_w and 0 <= y < self.img_h):
265
+ raise ToolError(f"({x}, {y}) is outside the {self.img_w}x{self.img_h} screenshot; "
266
+ "coordinates are pixels of the screenshot image")
267
+ px = self.mon["left"] + x * self.mon["width"] / self.img_w
268
+ py = self.mon["top"] + y * self.mon["height"] / self.img_h
269
+ return round(px), round(py)
270
+
271
+ # ---- what the model has seen
272
+ def mark_sent(self, frame: np.ndarray) -> None:
273
+ self.last_sent = frame
274
+ self.last_sent_at = time.monotonic()
275
+
276
+ def can_elide(self) -> bool:
277
+ return (ELIDE_TTL > 0 and self.last_sent is not None
278
+ and time.monotonic() - self.last_sent_at <= ELIDE_TTL)
279
+
280
+ # ---- regions we do not wait on
281
+ def effective_ignore(self) -> tuple:
282
+ now = time.monotonic()
283
+ for box in [b for b, t in self.auto_regions.items() if now - t > 600]:
284
+ del self.auto_regions[box] # forgotten entirely after 10 minutes
285
+ active = [b for b, t in self.auto_regions.items() if now - t < AUTO_IGNORE_SECS]
286
+ return tuple(self.base.ignore_regions) + tuple(active)
287
+
288
+ def learn_residual(self, box: tuple) -> bool:
289
+ """Called on every 'residual' bail. Returns True when the region is (re)activated for auto-ignore:
290
+ immediately if it was confirmed before, otherwise on the second consecutive bail."""
291
+ if AUTO_IGNORE_SECS <= 0:
292
+ return False
293
+ now = time.monotonic()
294
+ pad = 0.01
295
+ padded = (max(0.0, box[0] - pad), max(0.0, box[1] - pad), min(1.0, box[2] + pad), min(1.0, box[3] + pad))
296
+ for known in list(self.auto_regions):
297
+ if _overlap(known, padded) > 0.5:
298
+ self.auto_regions[known] = now
299
+ return True
300
+ if self.pending_residual is not None and _overlap(self.pending_residual, padded) > 0.5:
301
+ self.auto_regions[padded] = now
302
+ self.pending_residual = None
303
+ return True
304
+ self.pending_residual = padded
305
+ return False
306
+
307
+ # ---- encoding
308
+ def _encode_sync(self, frame: np.ndarray) -> bytes:
309
+ if cv2 is not None and frame.ndim == 3 and frame.shape[2] == 4:
310
+ bgr = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)
311
+ if bgr.shape[1] != self.img_w or bgr.shape[0] != self.img_h:
312
+ bgr = cv2.resize(bgr, (self.img_w, self.img_h), interpolation=cv2.INTER_AREA)
313
+ ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
314
+ if ok:
315
+ return enc.tobytes()
316
+ img = PILImage.fromarray(np.ascontiguousarray(frame[:, :, 2::-1])) # BGRA -> RGB
317
+ if img.size != (self.img_w, self.img_h):
318
+ img = img.resize((self.img_w, self.img_h), PILImage.BILINEAR)
319
+ buf = io.BytesIO()
320
+ img.save(buf, "JPEG", quality=JPEG_QUALITY)
321
+ return buf.getvalue()
322
+
323
+ async def encode(self, frame: np.ndarray) -> Image:
324
+ data = await asyncio.to_thread(self._encode_sync, frame) # keep the event loop free
325
+ return Image(data=data, format="jpeg")
326
+
327
+
328
+ state = State()
329
+
330
+
331
+ def _coords_note() -> str:
332
+ return f"Coordinates are pixels of this image ({state.img_w}x{state.img_h})."
333
+
334
+
335
+ async def _settle_for(key: str, action) -> SettleResult:
336
+ """Run one blocking input action on the input thread, wait for the UI to settle, record the sample.
337
+ Config precedence: configure()-pinned values > per-action defaults > adaptive learning."""
338
+ state.ensure()
339
+ cfg = state.book.config_for(key, state.base, pinned=frozenset(state.pinned))
340
+ defaults = {k: v for k, v in _KIND_DEFAULTS.get(key, {}).items() if k not in state.pinned}
341
+ cfg = replace(cfg, ignore_regions=state.effective_ignore(), **defaults)
342
+ res = await act_and_settle(state.grab, lambda: _run_input(action), cfg)
343
+ state.book.record(key, res)
344
+ state.last_frame = res.frame
345
+ return res
346
+
347
+
348
+ def _residual_advice(box: tuple) -> str:
349
+ if state.learn_residual(box):
350
+ return (f" Auto-ignoring this region for the next {AUTO_IGNORE_SECS:.0f}s so later actions do not wait "
351
+ "on it (it is re-checked afterwards).")
352
+ return (" If the moving region is irrelevant (video, ticker), call configure(ignore_regions="
353
+ f"[[{box[0]:.2f},{box[1]:.2f},{box[2]:.2f},{box[3]:.2f}]]) to stop waiting on it.")
354
+
355
+
356
+ async def _finish(res: SettleResult, kind: str = "action") -> list:
357
+ """Turn a SettleResult into the tool return. When nothing new is visible AND the model received an
358
+ image recently (ELIDE_TTL), the image is omitted to save vision tokens; otherwise it is attached."""
359
+ note = res.note_for_model(kind)
360
+ frame = res.frame
361
+ if res.reason == "residual" and res.motion_box:
362
+ note += _residual_advice(res.motion_box)
363
+ if res.reason in ("no_reaction", "residual") and state.can_elide():
364
+ ignore = list(state.effective_ignore())
365
+ if res.reason == "residual" and res.motion_box:
366
+ ignore.append(res.motion_box)
367
+ if _same_frame(frame, state.last_sent, ignore):
368
+ age = time.monotonic() - state.last_sent_at
369
+ where = "Outside the moving region the" if res.reason == "residual" else "The"
370
+ return [f"{note} {where} screen is unchanged since the last image you received "
371
+ f"({age:.0f}s ago), so no new image is attached. Call screenshot() if you are unsure."]
372
+ img = await state.encode(frame)
373
+ state.mark_sent(frame)
374
+ return [img, f"{note} {_coords_note()}"]
375
+
376
+
377
+ # --------------------------------------------------------------------------- key handling
378
+ _KEY_ALIASES = {
379
+ "cmd": "command" if _IS_MAC else "win", "super": "command" if _IS_MAC else "win",
380
+ "meta": "command" if _IS_MAC else "win", "windows": "win", "control": "ctrl",
381
+ "return": "enter", "esc": "escape", "del": "delete", "pgup": "pageup", "pgdn": "pagedown",
382
+ "option": "option" if _IS_MAC else "alt", "alt": "option" if _IS_MAC else "alt",
383
+ }
384
+
385
+
386
+ def _normalize_keys(spec: str) -> list:
387
+ parts = [p for p in spec.lower().replace(" ", "").split("+") if p]
388
+ keys = [_KEY_ALIASES.get(p, p) for p in parts]
389
+ bad = [k for k in keys if k not in pyautogui.KEYBOARD_KEYS]
390
+ if not keys or bad:
391
+ raise ToolError(f"unknown key(s) {bad or spec!r}; examples: 'enter', 'ctrl+s', 'alt+tab', 'shift+f10'")
392
+ return keys
393
+
394
+
395
+ def _press(combo: list) -> None:
396
+ pyautogui.press(combo[0]) if len(combo) == 1 else pyautogui.hotkey(*combo)
397
+
398
+
399
+ def _type(text: str) -> None:
400
+ if text.isascii():
401
+ pyautogui.write(text, interval=0)
402
+ return
403
+ try:
404
+ import pyperclip
405
+ except ImportError as exc:
406
+ raise ToolError("non-ASCII text needs `pip install pyperclip` (it pastes via the clipboard)") from exc
407
+ pyperclip.copy(text) # overwrites the clipboard
408
+ pyautogui.hotkey("command" if _IS_MAC else "ctrl", "v")
409
+
410
+
411
+ def _scroll(direction: str, amount: int, target: Optional[tuple]) -> None:
412
+ if target:
413
+ pyautogui.moveTo(*target)
414
+ if direction in ("up", "down"):
415
+ pyautogui.scroll(amount if direction == "up" else -amount)
416
+ else:
417
+ pyautogui.hscroll(amount if direction == "right" else -amount)
418
+
419
+
420
+ # --------------------------------------------------------------------------- MCP tools
421
+ class ActStep(TypedDict, total=False):
422
+ __pydantic_config__ = ConfigDict(extra="forbid") # a misspelled key is an error that names it
423
+ click: list[float] # [x, y] in screenshot pixels
424
+ dblclick: list[float]
425
+ rightclick: list[float]
426
+ move: list[float]
427
+ type: str
428
+ key: str
429
+ scroll: list # ["up"|"down"|"left"|"right", amount, optional x, optional y]
430
+ wait: float # seconds
431
+
432
+
433
+ _INSTRUCTIONS = (
434
+ "Desktop control with event-driven settling: every action waits until the screen reacts and "
435
+ "stops changing, then returns the settled screenshot plus a short note.\n"
436
+ "Rules that save you turns:\n"
437
+ "- Do not call screenshot() right after an action - the action's returned image IS the "
438
+ "after-state. Use screenshot() when you have no recent frame; it always returns an image.\n"
439
+ "- Prefer the `act` tool for sequences of known actions (e.g. click a field, type, press "
440
+ "enter): one call, one final image. Every step is validated before anything runs.\n"
441
+ "- An action response with NO image means the screen is unchanged since the last image you "
442
+ "received a few seconds ago; if you no longer have that image, call screenshot().\n"
443
+ "- 'No visible change' does not mean failure: focusing an already-focused field or setting a "
444
+ "state that is already set changes nothing. Judge by the image, not only the note.\n"
445
+ "- Follow the notes' explicit suggestions (e.g. configure ignore_regions for a video region)."
446
+ )
447
+
448
+ mcp = _Server("settled-computer", instructions=_INSTRUCTIONS)
449
+
450
+
451
+ @mcp.tool()
452
+ @serialized
453
+ async def screenshot():
454
+ """Look at the current screen (waits until it is visually stable). ALWAYS returns an image. Every action
455
+ already returns the settled screen, so use this only when you have no recent frame or after wait()."""
456
+ state.ensure()
457
+ cfg = replace(state.base, react_deadline=0.0, ignore_regions=state.effective_ignore())
458
+ res = await wait_settled(state.grab, cfg)
459
+ state.last_frame = res.frame
460
+ if res.reason == "timeout":
461
+ note = f"Screen was still changing after {res.waited:.1f}s (loading or animating); call wait()."
462
+ elif res.reason == "residual" and res.motion_box:
463
+ b = res.motion_box
464
+ note = (f"Screen is stable except a region [{b[0]:.2f},{b[1]:.2f},{b[2]:.2f},{b[3]:.2f}] that keeps "
465
+ f"animating (bailed after {res.waited:.1f}s). Everything outside it is stable; if that region "
466
+ "is what you are waiting on, call wait()." + _residual_advice(b))
467
+ else:
468
+ note = "Screen is stable."
469
+ img = await state.encode(res.frame)
470
+ state.mark_sent(res.frame)
471
+ return [img, f"{note} {_coords_note()}"]
472
+
473
+
474
+ @mcp.tool()
475
+ @gated('click')
476
+ @serialized
477
+ async def click(x: float, y: float, button: Literal["left", "right", "middle"] = "left", clicks: int = 1):
478
+ """Click at (x, y) in screenshot pixels. clicks=2 double-clicks. Returns the settled screen."""
479
+ px, py = state.to_point(x, y)
480
+ n = max(1, min(clicks, 3))
481
+ return await _finish(await _settle_for("click", lambda: pyautogui.click(px, py, clicks=n, button=button)),
482
+ "click")
483
+
484
+
485
+ @mcp.tool()
486
+ @gated('type_text')
487
+ @serialized
488
+ async def type_text(text: str):
489
+ """Type text into the focused control. Newlines press Enter. Returns the settled screen."""
490
+ return await _finish(await _settle_for("type", lambda: _type(text)), "type")
491
+
492
+
493
+ @mcp.tool()
494
+ @gated('press_key')
495
+ @serialized
496
+ async def press_key(keys: str):
497
+ """Press a key or shortcut, e.g. 'enter', 'tab', 'ctrl+s', 'alt+tab', 'cmd+space'. Returns the settled screen."""
498
+ combo = _normalize_keys(keys)
499
+ return await _finish(await _settle_for("key", lambda: _press(combo)), "key")
500
+
501
+
502
+ @mcp.tool()
503
+ @gated('scroll')
504
+ @serialized
505
+ async def scroll(direction: Literal["up", "down", "left", "right"], amount: int = 5,
506
+ x: Optional[float] = None, y: Optional[float] = None):
507
+ """Scroll by `amount` wheel clicks, optionally at (x, y) in screenshot pixels. Returns the settled screen."""
508
+ target = state.to_point(x, y) if x is not None and y is not None else None
509
+ amount = max(1, min(amount, 50))
510
+ return await _finish(await _settle_for("scroll", lambda: _scroll(direction, amount, target)), "scroll")
511
+
512
+
513
+ @mcp.tool()
514
+ @gated('drag')
515
+ @serialized
516
+ async def drag(x1: float, y1: float, x2: float, y2: float, duration: float = 0.3):
517
+ """Left-button drag from (x1, y1) to (x2, y2) in screenshot pixels. Returns the settled screen."""
518
+ a, b = state.to_point(x1, y1), state.to_point(x2, y2)
519
+ secs = max(0.05, min(duration, 3.0))
520
+
521
+ def action() -> None:
522
+ pyautogui.moveTo(*a)
523
+ pyautogui.dragTo(*b, duration=secs, button="left")
524
+
525
+ return await _finish(await _settle_for("drag", action), "drag")
526
+
527
+
528
+ @mcp.tool()
529
+ @gated('mouse_move')
530
+ @serialized
531
+ async def mouse_move(x: float, y: float):
532
+ """Move the pointer to (x, y) to reveal hover menus or tooltips. Returns the settled screen.
533
+ Not a way to check anything - it returns the screen like every other action does."""
534
+ px, py = state.to_point(x, y)
535
+ return await _finish(await _settle_for("hover", lambda: pyautogui.moveTo(px, py)), "hover")
536
+
537
+
538
+ # ---- act(): validate everything first, then run
539
+ _STOP_KINDS = {"click", "dblclick", "rightclick", "key"} # a no-op here usually means a mis-click
540
+
541
+
542
+ def _note_kind(kind: str) -> str:
543
+ return {"wait": "wait", "move": "hover", "type": "type"}.get(kind, "action")
544
+
545
+
546
+ def _plan_step(i: int, kind: str, arg):
547
+ """Validate one act() step WITHOUT touching the desktop; return an async callable that performs it
548
+ and settles. Validating every step up front means a typo in step 5 cannot leave 1-4 half-executed."""
549
+ def xy(a) -> tuple:
550
+ try:
551
+ x, y = float(a[0]), float(a[1])
552
+ except (TypeError, ValueError, IndexError, KeyError):
553
+ raise ToolError(f"step {i + 1}: {kind} expects [x, y] in screenshot pixels, got {a!r}") from None
554
+ return state.to_point(x, y)
555
+
556
+ if kind in ("click", "dblclick", "rightclick"):
557
+ px, py = xy(arg)
558
+ clicks, button = (2, "left") if kind == "dblclick" else (1, "right" if kind == "rightclick" else "left")
559
+ return lambda: _settle_for("click", lambda: pyautogui.click(px, py, clicks=clicks, button=button))
560
+ if kind == "move":
561
+ px, py = xy(arg)
562
+ return lambda: _settle_for("hover", lambda: pyautogui.moveTo(px, py))
563
+ if kind == "type":
564
+ text = str(arg)
565
+ return lambda: _settle_for("type", lambda: _type(text))
566
+ if kind == "key":
567
+ combo = _normalize_keys(str(arg))
568
+ return lambda: _settle_for("key", lambda: _press(combo))
569
+ if kind == "scroll":
570
+ if not isinstance(arg, (list, tuple)) or not arg:
571
+ raise ToolError(f'step {i + 1}: scroll expects ["up"|"down"|"left"|"right", amount, optional x, y]')
572
+ direction = arg[0]
573
+ if direction not in ("up", "down", "left", "right"):
574
+ raise ToolError(f"step {i + 1}: bad scroll direction {direction!r}")
575
+ try:
576
+ amount = max(1, min(int(arg[1]) if len(arg) > 1 else 5, 50))
577
+ except (TypeError, ValueError):
578
+ raise ToolError(f"step {i + 1}: scroll amount must be an integer, got {arg[1]!r}") from None
579
+ target = xy(arg[2:4]) if len(arg) >= 4 else None
580
+ return lambda: _settle_for("scroll", lambda: _scroll(direction, amount, target))
581
+ if kind == "wait":
582
+ try:
583
+ seconds = max(0.1, min(float(arg), 30.0))
584
+ except (TypeError, ValueError):
585
+ raise ToolError(f"step {i + 1}: wait expects a number of seconds, got {arg!r}") from None
586
+
587
+ async def run() -> SettleResult:
588
+ cfg = replace(state.base, react_deadline=seconds, max_wait=seconds + 5.0,
589
+ ignore_regions=state.effective_ignore())
590
+ res = await wait_settled(state.grab, cfg, baseline=state.last_frame)
591
+ state.last_frame = res.frame
592
+ return res
593
+ return run
594
+ raise ToolError(f"step {i + 1}: unknown kind {kind!r}; valid: click, dblclick, rightclick, "
595
+ "move, type, key, scroll, wait")
596
+
597
+
598
+ def _should_stop(res: SettleResult, kind: str, policy: str) -> bool:
599
+ if res.reason == "timeout":
600
+ return True
601
+ if res.reason == "no_reaction":
602
+ return policy == "always" or (policy == "auto" and kind in _STOP_KINDS)
603
+ return False # settled, or a residual (confined animation is not a failure)
604
+
605
+
606
+ @mcp.tool()
607
+ @gated('act')
608
+ @serialized
609
+ async def act(steps: list[ActStep], screenshot: Literal["final", "none"] = "final",
610
+ stop_on_no_reaction: Literal["auto", "always", "never"] = "auto"):
611
+ """Run a sequence of up to 8 actions in ONE call, each settling before the next. Steps are single-key
612
+ objects: {"click":[x,y]}, {"dblclick":[x,y]}, {"rightclick":[x,y]}, {"type":"text"}, {"key":"enter"},
613
+ {"scroll":["down",5]} (optionally ["down",5,x,y]), {"move":[x,y]}, {"wait":1.0}.
614
+ Every step is validated before anything runs. A timeout always stops the sequence. 'No visible change'
615
+ stops it only for click/dblclick/rightclick/key under stop_on_no_reaction="auto" (a click that does nothing
616
+ is usually a mis-click); use "never" when a no-op is expected (e.g. the field is already focused) or
617
+ "always" to stop on any no-op. Pass screenshot="none" to skip the final image when it completed cleanly."""
618
+ state.ensure()
619
+ if not isinstance(steps, list) or not (1 <= len(steps) <= 8):
620
+ raise ToolError("steps must be a list of 1-8 single-key action objects, "
621
+ 'e.g. [{"click":[640,400]}, {"type":"hello"}, {"key":"enter"}]')
622
+ plan = []
623
+ for i, step in enumerate(steps):
624
+ if not isinstance(step, dict) or len(step) != 1:
625
+ raise ToolError(f"step {i + 1}: expected a single-key object like "
626
+ '{"click":[640,400]}, got ' + repr(step))
627
+ kind, arg = next(iter(step.items()))
628
+ plan.append((kind, _plan_step(i, kind, arg)))
629
+
630
+ done: list[str] = []
631
+ quiet: list[str] = [] # steps that produced no visible change but did not stop the sequence
632
+ res: Optional[SettleResult] = None
633
+ for i, (kind, run) in enumerate(plan):
634
+ res = await run()
635
+ if _should_stop(res, kind, stop_on_no_reaction):
636
+ img = await state.encode(res.frame)
637
+ state.mark_sent(res.frame)
638
+ earlier = f" Done before it: {', '.join(done)}." if done else ""
639
+ remaining = ", ".join(f"{j + 1}:{plan[j][0]}" for j in range(i + 1, len(plan)))
640
+ later = f" Not run: {remaining}." if remaining else ""
641
+ hint = (" If no change was expected here (e.g. the field was already focused), re-run the remaining "
642
+ 'steps with stop_on_no_reaction="never".') if res.reason == "no_reaction" else ""
643
+ return [img, f"STOPPED at step {i + 1} ({kind}): {res.note_for_model(_note_kind(kind))}"
644
+ f"{earlier}{later}{hint} {_coords_note()}"]
645
+ if res.reason == "no_reaction":
646
+ quiet.append(f"{i + 1}:{kind}")
647
+ done.append(f"{i + 1}:{kind}")
648
+
649
+ summary = f"All {len(plan)} steps completed ({', '.join(done)})."
650
+ if quiet:
651
+ summary += f" No visible change from: {', '.join(quiet)}."
652
+ if res is not None and res.reason == "residual" and res.motion_box:
653
+ summary += " A confined region is still animating; the rest of the screen is stable." + _residual_advice(res.motion_box)
654
+ if screenshot == "none" and res is not None and res.reason in ("settled", "residual"):
655
+ return [summary + ' No image attached (screenshot="none").']
656
+ img = await state.encode(res.frame)
657
+ state.mark_sent(res.frame)
658
+ return [img, f"{summary} {_coords_note()}"]
659
+
660
+
661
+ @mcp.tool()
662
+ @serialized
663
+ async def wait(seconds: float = 3.0):
664
+ """Wait up to `seconds` for something to change (e.g. a slow page or a long operation), then wait
665
+ for it to settle. Use after a 'still changing'/'timeout' note or when a known slow operation is running."""
666
+ state.ensure()
667
+ seconds = max(0.1, min(seconds, 60.0))
668
+ cfg = replace(state.base, react_deadline=seconds, max_wait=seconds + 5.0,
669
+ ignore_regions=state.effective_ignore())
670
+ res = await wait_settled(state.grab, cfg, baseline=state.last_frame)
671
+ state.last_frame = res.frame
672
+ if res.reason == "no_reaction":
673
+ note = res.note_for_model("wait")
674
+ elif res.reason == "timeout":
675
+ note = f"Still changing after {res.waited:.1f}s."
676
+ else:
677
+ note = f"Screen changed and settled after {res.waited:.1f}s."
678
+ img = await state.encode(res.frame)
679
+ state.mark_sent(res.frame)
680
+ return [img, f"{note} {_coords_note()}"]
681
+
682
+
683
+ @mcp.tool()
684
+ @serialized
685
+ async def configure(quiet_time: Optional[float] = None, react_deadline: Optional[float] = None,
686
+ max_wait: Optional[float] = None, ignore_regions: Optional[list[list[float]]] = None,
687
+ residual_bail_after: Optional[float] = None) -> str:
688
+ """Tune settling. quiet_time: seconds of stillness that count as settled (0.05-5). react_deadline: how long
689
+ to wait for any change after an action (0-10). max_wait: hard cap per action (0.5-60). ignore_regions: list of
690
+ [x0, y0, x1, y1] screen fractions (0-1) to mask out, e.g. a clock or video; pass [] to clear (this also clears
691
+ auto-detected regions). residual_bail_after: return early when only a confined region keeps animating (0.5-10).
692
+ Values set here are pinned: adaptive learning and per-action defaults never override them."""
693
+ cfg = state.base
694
+ if quiet_time is not None:
695
+ cfg = replace(cfg, quiet_time=min(max(quiet_time, 0.05), 5.0))
696
+ state.pinned.add("quiet_time")
697
+ if react_deadline is not None:
698
+ cfg = replace(cfg, react_deadline=min(max(react_deadline, 0.0), 10.0))
699
+ state.pinned.add("react_deadline")
700
+ if max_wait is not None:
701
+ cfg = replace(cfg, max_wait=min(max(max_wait, 0.5), 60.0))
702
+ state.pinned.add("max_wait")
703
+ if residual_bail_after is not None:
704
+ cfg = replace(cfg, residual_bail_after=min(max(residual_bail_after, 0.5), 10.0))
705
+ if ignore_regions is not None:
706
+ for r in ignore_regions:
707
+ if len(r) != 4 or not all(0.0 <= v <= 1.0 for v in r) or r[0] >= r[2] or r[1] >= r[3]:
708
+ raise ToolError(f"bad region {r!r}: expected [x0, y0, x1, y1] fractions with x0<x1, y0<y1")
709
+ cfg = replace(cfg, ignore_regions=tuple(tuple(r) for r in ignore_regions))
710
+ if not ignore_regions:
711
+ state.auto_regions.clear()
712
+ state.pending_residual = None
713
+ state.base = cfg
714
+ return (f"quiet_time={cfg.quiet_time}s react_deadline={cfg.react_deadline}s max_wait={cfg.max_wait}s "
715
+ f"residual_bail_after={cfg.residual_bail_after}s "
716
+ f"ignore_regions={[list(r) for r in cfg.ignore_regions]} pinned={sorted(state.pinned)}")
717
+
718
+
719
+ @mcp.tool()
720
+ @serialized
721
+ async def screen_info() -> str:
722
+ """Describe the capture: image size the tools use, monitor geometry, platform."""
723
+ state.ensure()
724
+ auto = [[round(v, 2) for v in b] for b in state.effective_ignore()[len(state.base.ignore_regions):]]
725
+ return (f"Images are {state.img_w}x{state.img_h}px (native {state.native_w}x{state.native_h}); "
726
+ f"monitor {MONITOR} geometry {state.mon}; platform {platform.system()}; "
727
+ f"auto-ignored regions {auto}.")
728
+
729
+
730
+ # --------------------------------------------------------------------------- entry point
731
+ def _check() -> None:
732
+ grab, mon = _make_grabber(MONITOR)
733
+ grab() # warm up
734
+ t0 = time.perf_counter()
735
+ frame = grab()
736
+ ms = (time.perf_counter() - t0) * 1000
737
+ print(f"monitor {MONITOR}: {mon}")
738
+ print(f"frame {frame.shape[1]}x{frame.shape[0]}, grab {ms:.0f} ms, pyautogui size {tuple(pyautogui.size())}")
739
+ print(f"pointer now at {tuple(pyautogui.position())}; fail-safe {'on' if pyautogui.FAILSAFE else 'OFF'}")
740
+
741
+
742
+ def main() -> None:
743
+ """Console-script entry point (`settled-computer`)."""
744
+ ap = argparse.ArgumentParser(description="Settled computer-use MCP server (stdio)")
745
+ ap.add_argument("--check", action="store_true", help="test capture and coordinates, then exit")
746
+ args = ap.parse_args()
747
+ if args.check:
748
+ _check()
749
+ else:
750
+ mcp.run() # stdio transport
751
+
752
+
753
+ if __name__ == "__main__":
754
+ main()