ucu-mcp 0.3.7 → 0.3.8
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,21 @@ 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.3.8] - 2026-06-08
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `focus_app` no longer trips the user-activity pause. It used to be classified as `"other"` (neither observe nor input) so a recent mouse movement could block `focus_app` for 2 s; it is now in `OBSERVE_ACTIONS`, matching the production `withSafety` default. Symptom: OpenCode could not switch the active target app (e.g. CC Switch) without retrying until the cursor had been still for 2 s.
|
|
13
|
+
- `doctor` native-helper path resolution now checks `process.argv[1]` (npm / npx / global install), walks `import.meta.url` up to 4 levels, and falls back to `npm root -g`. Previously, when the MCP client launched `ucu-mcp` from a cwd other than the project root (the common case for `npx ucu-mcp`), the helper binaries would report as missing even though they were in the tarball. The new report includes `path` and a `tried[]` list so the model can see what was checked.
|
|
14
|
+
- `doctor` recommendations now list each missing macOS permission on its own line, name the host terminal app (so the user knows which entry to grant in System Settings), and add an Electron AX hint for the common case where `list_windows` returns `[]` even with Accessibility granted.
|
|
15
|
+
|
|
16
|
+
### Tests
|
|
17
|
+
|
|
18
|
+
- `safety-guard`: `focus_app` is in `OBSERVE_ACTIONS`; `classifyAction("focus_app") === "observe"`; `withSafety`'s default `skipUserActivityPause` lets the call through even mid user-activity.
|
|
19
|
+
- `errors`: `WindowNotFoundError` preserves an inline `hint` field set by the platform layer, surfaced in the MCP error response.
|
|
20
|
+
- `macos-platform`: OCR JXA `"Failed to load screenshot image"` is re-thrown as `CaptureError` with a hint pointing at the missing Screen Recording permission (the typical cause is `screencapture` writing a 0-byte file when TCC denies Screen Recording, not the helper binary being absent).
|
|
21
|
+
- `tools-layer`: `doctor` report carries `terminalApp` and the richer `nativeHelpers = { cgevent, ocr } = { ok, path, tried[] }` shape.
|
|
22
|
+
|
|
8
23
|
## [0.3.7] - 2026-06-07
|
|
9
24
|
|
|
10
25
|
### Fixed
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -94,13 +94,21 @@ function errorDetails(error) {
|
|
|
94
94
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
95
95
|
const code = error instanceof UcuError ? error.code : "UNKNOWN_ERROR";
|
|
96
96
|
const retryable = error instanceof UcuError ? error.retryable : false;
|
|
97
|
-
|
|
97
|
+
// Some platform errors carry an inline `hint` field (added by macos.ts focusApp
|
|
98
|
+
// for the Electron AX case, etc.). Surface it under `hint` so the model can
|
|
99
|
+
// see remediation without parsing the message string.
|
|
100
|
+
const inlineHint = err.hint;
|
|
101
|
+
const details = {
|
|
98
102
|
name: err.name,
|
|
99
103
|
code,
|
|
100
104
|
retryable,
|
|
101
105
|
message: err.message,
|
|
102
106
|
recovery: recoveryHint(code),
|
|
103
107
|
};
|
|
108
|
+
if (typeof inlineHint === "string" && inlineHint.length > 0) {
|
|
109
|
+
details.hint = inlineHint;
|
|
110
|
+
}
|
|
111
|
+
return details;
|
|
104
112
|
}
|
|
105
113
|
let _actionCounter = 0;
|
|
106
114
|
function nextActionId() {
|
|
@@ -281,7 +289,28 @@ export function registerTools(server) {
|
|
|
281
289
|
includeMinimized: z.boolean().optional().describe("Include minimized windows"),
|
|
282
290
|
}, async (params) => {
|
|
283
291
|
const windows = await withSafety({ action: "list_windows", params: {}, requiresAccessibility: true, execute: () => getPlatform().listWindows(params.includeMinimized) });
|
|
284
|
-
|
|
292
|
+
// Attach a diagnostic hint when the result is empty so the model can
|
|
293
|
+
// tell the difference between "no windows are open" and "AX enumeration
|
|
294
|
+
// failed for the target app" (common with Electron apps like CC Switch,
|
|
295
|
+
// VS Code, Discord). The windows list itself is the source of truth; the
|
|
296
|
+
// hint is advisory only.
|
|
297
|
+
let diagnostics;
|
|
298
|
+
if (windows.length === 0) {
|
|
299
|
+
let accessibility = "unknown";
|
|
300
|
+
try {
|
|
301
|
+
const { checkPermission } = await import("../safety/permissions.js");
|
|
302
|
+
const { granted } = await checkPermission("accessibility");
|
|
303
|
+
accessibility = granted ? "granted" : "denied";
|
|
304
|
+
}
|
|
305
|
+
catch { /* keep unknown */ }
|
|
306
|
+
const axNote = accessibility === "denied"
|
|
307
|
+
? "Accessibility is currently denied to this terminal — grant it via System Settings > Privacy & Security > Accessibility, then retry."
|
|
308
|
+
: accessibility === "granted"
|
|
309
|
+
? "Accessibility is granted. If you expected a specific app to appear here, it is likely an Electron app whose AX tree is not exposed to System Events; try modifying its config file or database directly rather than driving the UI."
|
|
310
|
+
: "Accessibility status is unknown. Run `doctor` first to verify.";
|
|
311
|
+
diagnostics = { hint: `list_windows returned 0 windows. ${axNote}`, accessibility };
|
|
312
|
+
}
|
|
313
|
+
return { content: [{ type: "text", text: JSON.stringify(diagnostics ? { windows, diagnostics } : windows, null, 2) }] };
|
|
285
314
|
});
|
|
286
315
|
registry.register("list_windows");
|
|
287
316
|
registerTool("list_apps", "List all running applications", {}, async () => {
|
|
@@ -384,55 +413,98 @@ export function registerTools(server) {
|
|
|
384
413
|
});
|
|
385
414
|
registry.register("drag");
|
|
386
415
|
registerTool("doctor", "Check system permissions, native helpers, and client readiness", {}, async () => {
|
|
387
|
-
const { checkPermissions } = await import("../safety/permissions.js");
|
|
416
|
+
const { checkPermissions, getPermissionInstructions, getTerminalAppName } = await import("../safety/permissions.js");
|
|
388
417
|
const { MacOSPlatform: MacPlat } = await import("../platform/macos.js");
|
|
389
|
-
const { existsSync } = await import("node:fs");
|
|
390
|
-
const { join, dirname } = await import("node:path");
|
|
418
|
+
const { existsSync, statSync } = await import("node:fs");
|
|
419
|
+
const { join, dirname, resolve } = await import("node:path");
|
|
391
420
|
const { fileURLToPath } = await import("node:url");
|
|
392
421
|
const { execFileSync } = await import("node:child_process");
|
|
393
422
|
const permissions = await checkPermissions();
|
|
394
423
|
const screenLocked = process.platform === "darwin" ? new MacPlat().isScreenLocked?.() ?? false : false;
|
|
395
|
-
|
|
396
|
-
|
|
424
|
+
const termApp = process.platform === "darwin" ? getTerminalAppName() : undefined;
|
|
425
|
+
// Resolve native helper binaries across every install layout we have seen:
|
|
426
|
+
// - dev: process.cwd() === project root
|
|
427
|
+
// - npm install --prefix X: argv[1] is in X/node_modules/ucu-mcp/...
|
|
428
|
+
// - global install via npm: argv[1] is in $(npm root -g)/ucu-mcp/...
|
|
429
|
+
// - npx: argv[1] is in ~/.npm/_npx/.../node_modules/ucu-mcp/...
|
|
430
|
+
// - bin/ucu-mcp.js is the entry; dist/src/*/tools.js is the module path
|
|
431
|
+
function resolveHelperPath(relParts) {
|
|
432
|
+
const tried = [];
|
|
433
|
+
const tryPaths = [];
|
|
397
434
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
435
|
+
const argv1 = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
436
|
+
const argv1Dir = argv1 ? dirname(argv1) : "";
|
|
437
|
+
// (1) process.cwd() — dev invocation
|
|
438
|
+
tryPaths.push(join(process.cwd(), ...relParts));
|
|
439
|
+
// (2) argv[1] dir — npm / npx / global
|
|
440
|
+
if (argv1Dir) {
|
|
441
|
+
tryPaths.push(join(argv1Dir, ...relParts));
|
|
442
|
+
tryPaths.push(join(argv1Dir, "..", ...relParts));
|
|
443
|
+
tryPaths.push(join(argv1Dir, "..", "..", ...relParts));
|
|
444
|
+
}
|
|
445
|
+
// (3) module dir — dist/bin or dist/src/mcp; walk up to 4 levels
|
|
446
|
+
tryPaths.push(join(moduleDir, "..", ...relParts));
|
|
447
|
+
tryPaths.push(join(moduleDir, "..", "..", ...relParts));
|
|
448
|
+
tryPaths.push(join(moduleDir, "..", "..", "..", ...relParts));
|
|
449
|
+
tryPaths.push(join(moduleDir, "..", "..", "..", "..", ...relParts));
|
|
450
|
+
// (4) npm root -g for global install (best effort)
|
|
451
|
+
if (process.platform === "darwin") {
|
|
452
|
+
try {
|
|
453
|
+
const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8", timeout: 2000 }).trim();
|
|
454
|
+
if (npmRoot) {
|
|
455
|
+
tryPaths.push(join(npmRoot, "ucu-mcp", ...relParts));
|
|
456
|
+
}
|
|
406
457
|
}
|
|
407
|
-
catch {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
458
|
+
catch { /* npm not on PATH is fine */ }
|
|
459
|
+
}
|
|
460
|
+
for (const p of tryPaths) {
|
|
461
|
+
tried.push(p);
|
|
462
|
+
try {
|
|
463
|
+
if (existsSync(p) && statSync(p).isFile())
|
|
464
|
+
return { path: p, tried };
|
|
465
|
+
}
|
|
466
|
+
catch { /* skip */ }
|
|
467
|
+
}
|
|
468
|
+
return { path: null, tried };
|
|
469
|
+
}
|
|
470
|
+
let nativeHelpers;
|
|
471
|
+
if (process.platform === "darwin") {
|
|
472
|
+
const cgevent = resolveHelperPath(["native", "cgevent", "cgevent-helper"]);
|
|
473
|
+
const ocr = resolveHelperPath(["native", "ocr", "ocr-helper"]);
|
|
411
474
|
nativeHelpers = {
|
|
412
|
-
cgevent:
|
|
413
|
-
ocr:
|
|
475
|
+
cgevent: { ok: cgevent.path !== null, path: cgevent.path, tried: cgevent.tried.slice(0, 3) },
|
|
476
|
+
ocr: { ok: ocr.path !== null, path: ocr.path, tried: ocr.tried.slice(0, 3) },
|
|
414
477
|
};
|
|
415
478
|
}
|
|
416
479
|
let readiness = "ready";
|
|
417
480
|
const issues = [];
|
|
418
481
|
if (!permissions.granted) {
|
|
419
482
|
readiness = "blocked";
|
|
420
|
-
|
|
483
|
+
for (const m of (permissions.missing ?? [])) {
|
|
484
|
+
issues.push(`Missing macOS permission: ${m}`);
|
|
485
|
+
}
|
|
421
486
|
}
|
|
422
487
|
if (screenLocked) {
|
|
423
488
|
readiness = "blocked";
|
|
424
489
|
issues.push("Screen is locked");
|
|
425
490
|
}
|
|
426
491
|
if (process.platform === "darwin" && nativeHelpers) {
|
|
427
|
-
if (!nativeHelpers.cgevent) {
|
|
492
|
+
if (!nativeHelpers.cgevent.ok) {
|
|
428
493
|
readiness = readiness === "ready" ? "degraded" : readiness;
|
|
429
|
-
issues.push("Native CGEvent helper not found (input synthesis may crash on macOS Sequoia+)");
|
|
494
|
+
issues.push("Native CGEvent helper not found (input synthesis may crash on macOS Sequoia+). Run `npm run build` to compile it, or reinstall ucu-mcp so the helper ships from the tarball.");
|
|
430
495
|
}
|
|
431
|
-
if (!nativeHelpers.ocr) {
|
|
496
|
+
if (!nativeHelpers.ocr.ok) {
|
|
432
497
|
readiness = readiness === "ready" ? "degraded" : readiness;
|
|
433
|
-
issues.push("Native OCR helper not found (OCR may fail on macOS Sequoia+)");
|
|
498
|
+
issues.push("Native OCR helper not found (OCR may fail on macOS Sequoia+). Run `npm run build` to compile it, or reinstall ucu-mcp so the helper ships from the tarball.");
|
|
434
499
|
}
|
|
435
500
|
}
|
|
501
|
+
// Heuristic AX hint: if Accessibility is granted but list_windows consistently
|
|
502
|
+
// returns empty for the only app the model cared about, the model has likely
|
|
503
|
+
// hit the Electron AX limitation (Electron windows do not expose AX to System
|
|
504
|
+
// Events unless Accessibility is also granted to the Electron process itself,
|
|
505
|
+
// and the app has accessibility features enabled). This block is read-only —
|
|
506
|
+
// we never hit JXA here because the doctor must stay fast and side-effect free.
|
|
507
|
+
const electronHint = "If the target app is Electron (e.g. CC Switch, VS Code, Discord), list_windows may return [] even with Accessibility granted to your terminal. Grant Accessibility to the Electron app itself in System Settings > Privacy & Security > Accessibility, and restart the app. As a workaround, modify the app\'s config file or database directly rather than driving the UI.";
|
|
436
508
|
const clients = {};
|
|
437
509
|
for (const bin of ["claude", "codex", "opencode", "npx"]) {
|
|
438
510
|
try {
|
|
@@ -445,16 +517,24 @@ export function registerTools(server) {
|
|
|
445
517
|
}
|
|
446
518
|
const recommendations = [];
|
|
447
519
|
if (readiness === "blocked") {
|
|
448
|
-
|
|
520
|
+
for (const m of (permissions.missing ?? [])) {
|
|
521
|
+
const app = termApp ?? "your terminal app";
|
|
522
|
+
recommendations.push(`${m}: ${getPermissionInstructions(m)} (Grant to ${app}.)`);
|
|
523
|
+
}
|
|
524
|
+
if (screenLocked)
|
|
525
|
+
recommendations.push("Unlock the screen, then retry.");
|
|
449
526
|
}
|
|
450
|
-
|
|
451
|
-
if (nativeHelpers && (!nativeHelpers.cgevent || !nativeHelpers.ocr)) {
|
|
452
|
-
recommendations.push("Run
|
|
527
|
+
if (readiness !== "ready") {
|
|
528
|
+
if (process.platform === "darwin" && nativeHelpers && (!nativeHelpers.cgevent.ok || !nativeHelpers.ocr.ok)) {
|
|
529
|
+
recommendations.push("Run `npm run build` in the ucu-mcp project to compile native Swift helpers (cgevent-helper, ocr-helper).");
|
|
453
530
|
}
|
|
454
531
|
}
|
|
455
|
-
|
|
532
|
+
if (readiness === "ready") {
|
|
456
533
|
recommendations.push("All checks passed. MCP client can proceed with automation.");
|
|
457
534
|
}
|
|
535
|
+
else if (process.platform === "darwin") {
|
|
536
|
+
recommendations.push(electronHint);
|
|
537
|
+
}
|
|
458
538
|
const report = {
|
|
459
539
|
readiness,
|
|
460
540
|
issues: issues.length > 0 ? issues : undefined,
|
|
@@ -463,6 +543,7 @@ export function registerTools(server) {
|
|
|
463
543
|
node: process.version,
|
|
464
544
|
permissions,
|
|
465
545
|
screenLocked,
|
|
546
|
+
terminalApp: termApp,
|
|
466
547
|
nativeHelpers,
|
|
467
548
|
clients,
|
|
468
549
|
safety: {
|
|
@@ -248,7 +248,21 @@ export class MacOSPlatform {
|
|
|
248
248
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
249
249
|
} while (Date.now() < deadline);
|
|
250
250
|
if (!target) {
|
|
251
|
-
|
|
251
|
+
// Wrap with a more diagnostic message: many real-world failures are
|
|
252
|
+
// Electron apps that do not expose their AX tree to System Events
|
|
253
|
+
// (CC Switch, VS Code, Discord, Slack). WindowNotFoundError carries the
|
|
254
|
+
// app name so the tool handler can surface a remediation hint. The
|
|
255
|
+
// bare WindowNotFoundError("CC Switch") was indistinguishable from
|
|
256
|
+
// "the app is not running", which led models to retry forever.
|
|
257
|
+
const err = new WindowNotFoundError(app);
|
|
258
|
+
err.hint =
|
|
259
|
+
"list_windows returned no match for this app. If the app is running, " +
|
|
260
|
+
"the most likely cause is that it is an Electron app whose AX tree is " +
|
|
261
|
+
"not exposed to System Events (System Settings > Privacy & Security > " +
|
|
262
|
+
"Accessibility must be granted to the Electron process itself, not just " +
|
|
263
|
+
"to the host terminal). As a workaround, modify the app's config file " +
|
|
264
|
+
"or database directly.";
|
|
265
|
+
throw err;
|
|
252
266
|
}
|
|
253
267
|
this.activeTarget = {
|
|
254
268
|
targetId: randomUUID(),
|
|
@@ -779,8 +793,18 @@ export class MacOSPlatform {
|
|
|
779
793
|
`;
|
|
780
794
|
const out = execFileSync("osascript", ["-l", "JavaScript", "-e", jxaScript], { encoding: "utf-8", timeout: 30000 }).trim();
|
|
781
795
|
const parsed = JSON.parse(out);
|
|
782
|
-
if (parsed.error)
|
|
783
|
-
|
|
796
|
+
if (parsed.error) {
|
|
797
|
+
// Distinguish permission-class failures from real Vision errors.
|
|
798
|
+
// screencapture writes a 0-byte file when Screen Recording is not granted,
|
|
799
|
+
// and the JXA NSImage init then fails with "Failed to load screenshot image".
|
|
800
|
+
// Surface that as a PermissionError hint so the model can suggest the right fix.
|
|
801
|
+
const hint = parsed.error === "Failed to load screenshot image"
|
|
802
|
+
? " (the screenshot file is empty or unreadable — Screen Recording permission is most likely missing; run `doctor` and grant Screen Recording to the host terminal, then retry)"
|
|
803
|
+
: parsed.error === "Failed to get CGImage from screenshot"
|
|
804
|
+
? " (the screenshot could not be decoded — likely an empty capture; check Screen Recording permission)"
|
|
805
|
+
: "";
|
|
806
|
+
throw new CaptureError(`ocr failed: ${parsed.error}${hint}`);
|
|
807
|
+
}
|
|
784
808
|
const imgWidth = buf.readUInt32BE(16);
|
|
785
809
|
const scaleFactorX = screenSize.width / (region ? region.width : (imgWidth / scaleFactor));
|
|
786
810
|
const elements = parsed.elements.map((el) => ({
|
package/dist/src/safety/guard.js
CHANGED
|
@@ -100,6 +100,10 @@ export const OBSERVE_ACTIONS = new Set([
|
|
|
100
100
|
"wait_for_element",
|
|
101
101
|
"doctor",
|
|
102
102
|
"clipboard_read",
|
|
103
|
+
// focus_app only sets the active target context via AppleScript activate
|
|
104
|
+
// and an AX window lookup — it does not synthesize mouse or keyboard input,
|
|
105
|
+
// so the user-activity pause must not block it. (OpenCode 0.3.7 follow-up)
|
|
106
|
+
"focus_app",
|
|
103
107
|
]);
|
|
104
108
|
/** Actions that synthesize user input — need full user-activity protection. */
|
|
105
109
|
export const INPUT_ACTIONS = new Set([
|
|
@@ -8,6 +8,10 @@ export interface PermissionDetail {
|
|
|
8
8
|
granted: boolean;
|
|
9
9
|
instructions: string;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Get the name of the terminal app that the user needs to authorize.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getTerminalAppName(): string;
|
|
11
15
|
export declare function checkPermissions(): Promise<PermissionCheckResult>;
|
|
12
16
|
export declare function checkPermission(type: "accessibility" | "screenRecording"): Promise<{
|
|
13
17
|
granted: boolean;
|
|
@@ -4,7 +4,7 @@ const execFileAsync = promisify(execFile);
|
|
|
4
4
|
/**
|
|
5
5
|
* Get the name of the terminal app that the user needs to authorize.
|
|
6
6
|
*/
|
|
7
|
-
function getTerminalAppName() {
|
|
7
|
+
export function getTerminalAppName() {
|
|
8
8
|
// Walk up the process tree to find the terminal emulator
|
|
9
9
|
const ppid = process.ppid;
|
|
10
10
|
// Common terminal app names
|