ucu-mcp 0.3.9 → 0.4.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-06-11
9
+
10
+ ### Fixed
11
+
12
+ - **JXA return values fixed (P0)**: Three JXA scripts (`click_element`, `type_in_element`, `set_value`) called `JSON.stringify({success:…})` as a bare statement — the result was computed but discarded, so the osascript output was empty and `JSON.parse(out)` would fail or return undefined. Now each script assigns to `_result` and calls `JSON.stringify(_result)` once at the end.
13
+ - **Rate-limit timestamp ordering (P0)**: `lastActionTime` was updated before the user-activity pause check. If the pause blocked the action, the rate-limit window was consumed anyway, causing subsequent retries to also be rate-limited. Now `lastActionTime` is set only after both checks pass.
14
+ - **Window cache concurrency guard (P0)**: `listWindows` could be called concurrently (e.g. `validateActiveTarget` + `list_windows` tool). Two overlapping calls could write `windowCache` at the same time, producing torn reads. Added `windowCacheInFlight` flag — concurrent callers return stale data instead of racing.
15
+ - **`validateActiveTarget` checks pid (P1)**: Previously only checked windowId, missing the case where an app restarts and the OS reuses the same window ID. Now also checks pid match.
16
+ - **`focusApp` failure clears stale target (P1)**: When `focusApp` threw `WindowNotFoundError`, the old `activeTarget` was retained. Subsequent AX tools would try to use the dead target. Now `activeTarget` is cleared on failure.
17
+ - **`get_screen_size` goes through `withSafety` (P1)**: Was the only tool that bypassed the safety/permission/retry pipeline. Now wrapped in `withSafety` for consistent error handling and rate limiting.
18
+
19
+ ### Tests
20
+
21
+ - 225 unit tests pass (13 test files).
22
+ - MCP stdio smoke: `doctor`, `list_windows`, `list_apps`, `get_screen_size` all return valid responses.
23
+ - All 3 JXA scripts now produce valid JSON output (verified via stdio pipe test).
24
+
25
+
8
26
  ## [0.3.8] - 2026-06-08
9
27
 
10
28
  ### Fixed
@@ -208,7 +208,13 @@ async function withSafety(sa) {
208
208
  }
209
209
  if (sa.dryRun)
210
210
  return `[DRY-RUN] ${await sa.dryRun()}`;
211
- const shouldManageFocus = sa.requiresAccessibility && !["screenshot", "list_windows", "list_apps", "get_window_state", "get_cursor_position", "get_screen_size", "ocr", "doctor", "wait", "wait_for_element", "find_element", "focus_app"].includes(sa.action);
211
+ // Focus management is disabled by default: CGEvent input injection works
212
+ // at the HID level without requiring the target app to be frontmost, and
213
+ // AX operations target processes by name/PID via System Events. The user
214
+ // should remain in their current app while the agent works in the background.
215
+ // Re-enable saveFocus/restoreFocus only if a specific AX operation truly
216
+ // requires the target app to be frontmost (rare).
217
+ const shouldManageFocus = false;
212
218
  if (shouldManageFocus)
213
219
  await platform.saveFocus?.();
214
220
  const start = Date.now();
@@ -634,7 +640,8 @@ export function registerTools(server) {
634
640
  registerTool("get_screen_size", "Get screen dimensions and scale factor", {
635
641
  display: z.number().optional().describe("Display index"),
636
642
  }, async (params) => {
637
- return { content: [{ type: "text", text: JSON.stringify(getPlatform().getScreenSize(params.display), null, 2) }] };
643
+ const result = await withSafety({ action: "get_screen_size", params: {}, execute: () => Promise.resolve(getPlatform().getScreenSize(params.display)) });
644
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
638
645
  });
639
646
  registry.register("get_screen_size");
640
647
  registerTool("ocr", "Perform OCR on screen region", {
@@ -15,6 +15,7 @@ export declare class MacOSPlatform implements Platform {
15
15
  private readonly elementCacheMaxSize;
16
16
  private readonly windowCacheTtlMs;
17
17
  private windowCache;
18
+ private windowCacheInFlight;
18
19
  private activeTarget;
19
20
  private savedFocus;
20
21
  constructor(options?: MacOSPlatformOptions);
@@ -69,6 +69,7 @@ export class MacOSPlatform {
69
69
  elementCacheMaxSize = 100;
70
70
  windowCacheTtlMs = 300;
71
71
  windowCache;
72
+ windowCacheInFlight = false;
72
73
  activeTarget;
73
74
  savedFocus;
74
75
  constructor(options) {
@@ -114,8 +115,12 @@ export class MacOSPlatform {
114
115
  return;
115
116
  this.windowCache = undefined; // Bypass cache — stale detection must use fresh data
116
117
  const windows = await this.listWindows(true);
117
- const stillExists = windows.some(w => w.id === this.activeTarget.windowId);
118
- if (!stillExists) {
118
+ const match = windows.find(w => w.id === this.activeTarget.windowId);
119
+ if (!match) {
120
+ throw new TargetStaleError(this.activeTarget.windowId);
121
+ }
122
+ // Also invalidate if pid changed (app restarted)
123
+ if (match.pid !== this.activeTarget.pid) {
119
124
  throw new TargetStaleError(this.activeTarget.windowId);
120
125
  }
121
126
  }
@@ -239,13 +244,14 @@ export class MacOSPlatform {
239
244
  async focusApp(app) {
240
245
  const escapedApp = app.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
241
246
  this.windowCache = undefined;
242
- try {
243
- execFileSync("osascript", ["-e", `tell application "${escapedApp}" to activate`], { timeout: 5000 });
244
- }
245
- catch {
246
- // Some app names are process labels rather than AppleScript application names.
247
- // Continue with the AX window lookup below so existing callers still work.
248
- }
247
+ // NOTE: We intentionally do NOT call AppleScript "activate" here.
248
+ // focus_app sets the internal target context so subsequent operations
249
+ // know which app/window to target. It does NOT bring the app to the
250
+ // foreground — the user should remain in their current app (terminal,
251
+ // Codex, etc.) while the agent works in the background.
252
+ // CGEvent input injection works at the HID level and doesn't require
253
+ // the target app to be frontmost. AX operations target processes by
254
+ // name/PID via System Events, also without needing frontmost status.
249
255
  let target;
250
256
  const deadline = Date.now() + 3000;
251
257
  do {
@@ -262,6 +268,7 @@ export class MacOSPlatform {
262
268
  // app name so the tool handler can surface a remediation hint. The
263
269
  // bare WindowNotFoundError("CC Switch") was indistinguishable from
264
270
  // "the app is not running", which led models to retry forever.
271
+ this.activeTarget = undefined; // Clear stale target on focus failure
265
272
  const err = new WindowNotFoundError(app);
266
273
  err.hint =
267
274
  "list_windows returned no match for this app. If the app is running, " +
@@ -343,6 +350,12 @@ export class MacOSPlatform {
343
350
  bounds: { ...window.bounds },
344
351
  }));
345
352
  }
353
+ // P0 #3: Prevent concurrent cache refreshes
354
+ if (this.windowCacheInFlight) {
355
+ // Another call is already refreshing; return stale or empty
356
+ return this.windowCache?.windows.map(w => ({ ...w, bounds: { ...w.bounds } })) ?? [];
357
+ }
358
+ this.windowCacheInFlight = true;
346
359
  try {
347
360
  // Try native Swift helper first (CGWindowListCopyWindowInfo, ~1ms).
348
361
  // Falls back to JXA System Events if the helper is not available.
@@ -370,6 +383,9 @@ export class MacOSPlatform {
370
383
  // Fallback: return empty list if both methods fail
371
384
  return [];
372
385
  }
386
+ finally {
387
+ this.windowCacheInFlight = false;
388
+ }
373
389
  }
374
390
  listWindowsNative() {
375
391
  try {
@@ -1177,6 +1193,7 @@ export class MacOSPlatform {
1177
1193
  const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
1178
1194
  const jxaScript = `
1179
1195
  var se = Application('System Events');
1196
+ var _result = null;
1180
1197
  function childElements(elem) {
1181
1198
  try { return elem.uiElements(); } catch(e1) {
1182
1199
  try { return elem.elements(); } catch(e2) { return []; }
@@ -1354,11 +1371,11 @@ export class MacOSPlatform {
1354
1371
  }
1355
1372
 
1356
1373
  if (!elem) {
1357
- JSON.stringify({success: false, error: "Element not found: " + elemPath});
1374
+ _result = {success: false, error: "Element not found: " + elemPath};
1358
1375
  } else {
1359
1376
  try {
1360
1377
  elem.actions.AXPress.perform();
1361
- JSON.stringify({success: true});
1378
+ _result = {success: true};
1362
1379
  } catch(e) {
1363
1380
  try {
1364
1381
  var pos = elem.position();
@@ -1372,12 +1389,13 @@ export class MacOSPlatform {
1372
1389
  $.CGEventPost($.kCGHIDEventTap, down);
1373
1390
  var up = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseUp, pt, $.kCGMouseButtonLeft);
1374
1391
  $.CGEventPost($.kCGHIDEventTap, up);
1375
- JSON.stringify({success: true});
1392
+ _result = {success: true};
1376
1393
  } catch(e2) {
1377
- JSON.stringify({success: false, error: "Could not click element: " + String(e2.message || e2)});
1394
+ _result = {success: false, error: "Could not click element: " + String(e2.message || e2)};
1378
1395
  }
1379
1396
  }
1380
1397
  }
1398
+ JSON.stringify(_result);
1381
1399
  `;
1382
1400
  try {
1383
1401
  const out = execFileSync("osascript", [
@@ -1408,6 +1426,7 @@ export class MacOSPlatform {
1408
1426
  const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
1409
1427
  const jxaScript = `
1410
1428
  var se = Application('System Events');
1429
+ var _result = null;
1411
1430
  function childElements(elem) {
1412
1431
  try { return elem.uiElements(); } catch(e1) {
1413
1432
  try { return elem.elements(); } catch(e2) { return []; }
@@ -1587,7 +1606,7 @@ export class MacOSPlatform {
1587
1606
  }
1588
1607
 
1589
1608
  if (!elem) {
1590
- JSON.stringify({success: false, error: "Element not found: " + elemPath});
1609
+ _result = {success: false, error: "Element not found: " + elemPath};
1591
1610
  } else {
1592
1611
  try {
1593
1612
  elem.focused = true;
@@ -1614,13 +1633,15 @@ export class MacOSPlatform {
1614
1633
  if (!didSet) {
1615
1634
  try {
1616
1635
  se.keystroke(textToType);
1636
+ _result = {success: true};
1617
1637
  } catch(e) {
1618
- JSON.stringify({success: false, error: "Could not type into element: " + String(e.message || e)});
1638
+ _result = {success: false, error: "Could not type into element: " + String(e.message || e)};
1619
1639
  }
1640
+ } else {
1641
+ _result = {success: true};
1620
1642
  }
1621
-
1622
- JSON.stringify({success: true});
1623
1643
  }
1644
+ JSON.stringify(_result);
1624
1645
  `;
1625
1646
  try {
1626
1647
  const out = execFileSync("osascript", [
@@ -1669,6 +1690,7 @@ export class MacOSPlatform {
1669
1690
  const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
1670
1691
  const jxaScript = `
1671
1692
  var se = Application('System Events');
1693
+ var _result = null;
1672
1694
  function childElements(elem) {
1673
1695
  try { return elem.uiElements(); } catch(e1) {
1674
1696
  try { return elem.elements(); } catch(e2) { return []; }
@@ -1842,15 +1864,16 @@ export class MacOSPlatform {
1842
1864
  }
1843
1865
 
1844
1866
  if (!elem) {
1845
- JSON.stringify({success: false, error: "Element not found: " + elemPath});
1867
+ _result = {success: false, error: "Element not found: " + elemPath};
1846
1868
  } else {
1847
1869
  try {
1848
1870
  elem.value = valueToSet;
1849
- JSON.stringify({success: true});
1871
+ _result = {success: true};
1850
1872
  } catch(e) {
1851
- JSON.stringify({success: false, error: "Could not set AX value: " + String(e.message || e)});
1873
+ _result = {success: false, error: "Could not set AX value: " + String(e.message || e)};
1852
1874
  }
1853
1875
  }
1876
+ JSON.stringify(_result);
1854
1877
  `;
1855
1878
  try {
1856
1879
  const out = execFileSync("osascript", [
@@ -233,7 +233,6 @@ export class SafetyGuard {
233
233
  reason: `Rate-limited: ${elapsed}ms since last action (min ${this.rateLimitMs}ms)`,
234
234
  };
235
235
  }
236
- this.lastActionTime = now;
237
236
  // 6. User activity pause (skipped for observe-class actions) -----------------
238
237
  if (!options.skipUserActivityPause && this.isUserActivityPauseActive()) {
239
238
  return {
@@ -241,6 +240,7 @@ export class SafetyGuard {
241
240
  reason: `User activity detected — pausing automation for ${this.userActivityPauseMs}ms`,
242
241
  };
243
242
  }
243
+ this.lastActionTime = now;
244
244
  return { allowed: true };
245
245
  }
246
246
  // -----------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucu-mcp",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Universal Computer Use — desktop automation for AI agents via Model Context Protocol",
5
5
  "type": "module",
6
6
  "bin": {