ucu-mcp 0.4.0 → 0.4.3
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 +50 -4
- package/dist/bin/ucu-mcp.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/tools/app-tools.d.ts +2 -0
- package/dist/src/mcp/tools/app-tools.js +225 -0
- package/dist/src/mcp/tools/element-tools.d.ts +23 -0
- package/dist/src/mcp/tools/element-tools.js +59 -0
- package/dist/src/mcp/tools/helpers.d.ts +84 -0
- package/dist/src/mcp/tools/helpers.js +247 -0
- package/dist/src/mcp/tools/index.d.ts +19 -0
- package/dist/src/mcp/tools/index.js +55 -0
- package/dist/src/mcp/tools/input-tools.d.ts +2 -0
- package/dist/src/mcp/tools/input-tools.js +66 -0
- package/dist/src/mcp/tools/keyboard-tools.d.ts +2 -0
- package/dist/src/mcp/tools/keyboard-tools.js +35 -0
- package/dist/src/mcp/tools/screen-tools.d.ts +2 -0
- package/dist/src/mcp/tools/screen-tools.js +69 -0
- package/dist/src/mcp/tools.d.ts +9 -0
- package/dist/src/mcp/tools.js +87 -23
- package/dist/src/platform/base.d.ts +3 -0
- package/dist/src/platform/jxa-helpers.d.ts +11 -0
- package/dist/src/platform/jxa-helpers.js +206 -0
- package/dist/src/platform/macos/ax-tree.d.ts +4 -0
- package/dist/src/platform/macos/ax-tree.js +462 -0
- package/dist/src/platform/macos/base.d.ts +57 -0
- package/dist/src/platform/macos/base.js +92 -0
- package/dist/src/platform/macos/clipboard.d.ts +3 -0
- package/dist/src/platform/macos/clipboard.js +20 -0
- package/dist/src/platform/macos/element.d.ts +4 -0
- package/dist/src/platform/macos/element.js +212 -0
- package/dist/src/platform/macos/focus.d.ts +3 -0
- package/dist/src/platform/macos/focus.js +33 -0
- package/dist/src/platform/macos/helpers.d.ts +35 -0
- package/dist/src/platform/macos/helpers.js +54 -0
- package/dist/src/platform/macos/index.d.ts +2 -0
- package/dist/src/platform/macos/index.js +1 -0
- package/dist/src/platform/macos/input.d.ts +9 -0
- package/dist/src/platform/macos/input.js +62 -0
- package/dist/src/platform/macos/screen.d.ts +7 -0
- package/dist/src/platform/macos/screen.js +197 -0
- package/dist/src/platform/macos/window.d.ts +6 -0
- package/dist/src/platform/macos/window.js +251 -0
- package/dist/src/platform/macos.js +71 -563
- package/dist/src/util/errors.d.ts +7 -2
- package/dist/src/util/errors.js +7 -3
- package/native/cgevent/cgevent-helper +0 -0
- package/native/ocr/ocr-helper +0 -0
- package/native/windowlist/windowlist-helper +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,54 @@ 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.
|
|
8
|
+
## [0.4.2] - 2026-06-13
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **JXA escaping unified (P0/P1)**: All 8 JXA code-injection sites that built strings via manual `.replace()` escaping now use `JSON.stringify()` for every interpolated value. Eliminates shell-substitution, backtick, newline, and AppleScript injection vectors across `restoreFocus`, `focusApp`, `getActiveBrowserContext`, `getWindowState`, `ocrJxa`, `findElement`, `clickElement`, `typeInElement`, `setElementValue`. (SEC-P0-1, SEC-P0-2, SEC-P1-2, SEC-P1-3, ERR-P1-6)
|
|
13
|
+
- **`isScreenLocked` fail-closed (P1)**: Previously returned `false` when the `ioreg` check threw, letting actions proceed on a potentially locked screen. Now returns `true` on error so actions are blocked until lock state can be confirmed. (ERR-P1-4)
|
|
14
|
+
- **Window-skip / URL blocklist guards activated (P1)**: `SafetyGuard`'s `windowTitle` and `url` checks were dead code because no tool handler passed those fields. New `getSafetyContext()` helper resolves both from the active target and is spread into all 11 action tools' `withSafety` params. (SEC-P1-1)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `listApps` and `listWindowsJxa` now wrap their `osascript` calls in `try/catch` and rethrow via `rethrowAccessibilityError`, so a permission failure surfaces as `PermissionError("accessibility")` with a recovery hint instead of a generic `PlatformError`. (ERR-P1-3)
|
|
19
|
+
- `getScreenSize` logs the failure and returns an `{ estimated: true }` flag on the fallback `1920x1080` value, instead of silently returning a default that callers cannot distinguish from a real measurement. (ERR-P1-5)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`macos.ts` split into 10 domain modules**: The 1995-line monolith is now `base.ts`, `helpers.ts`, `focus.ts`, `screen.ts`, `window.ts`, `ax-tree.ts`, `input.ts`, `element.ts`, `clipboard.ts`, `index.ts` under `src/platform/macos/`. `base.ts` defines the class and re-binds methods; all `(this as any)` casts removed.
|
|
24
|
+
- **`tools.ts` split into 7 domain modules**: The 908-line tool registry is now `helpers.ts`, `screen-tools.ts`, `input-tools.ts`, `keyboard-tools.ts`, `element-tools.ts`, `app-tools.ts`, `index.ts` under `src/mcp/tools/`. A `ToolRegistry` class + `registerTool` callback pattern replaces the flat registration.
|
|
25
|
+
- **JXA helper templates extracted**: New `src/platform/jxa-helpers.ts` (216 lines) centralizes the `childElements`, `resolveElementByFullPath`, `resolveElementInApp`, `elemString`, `getBounds`, `isVisible`, `descriptorMatches`, `scoreEquivalent`, `refetchEquivalent` JXA functions. `element.ts` and `ax-tree.ts` now import and interpolate these instead of inlining 3× duplicated copies.
|
|
26
|
+
- `FindElementResult` type now has formal `subrole?: string` and `identifier?: string` fields; the `as any` casts that read them are gone.
|
|
27
|
+
|
|
28
|
+
### Tests
|
|
29
|
+
|
|
30
|
+
- 279 tests pass (13 unit + 2 integration), 12 skipped (2 GUI smoke suites gated by env vars). +34 new security tests: clipboard injection patterns (shell substitution, backtick, chaining, piping, JXA/AppleScript injection), permission-denied paths for all AX element tools, and platform-method integration coverage.
|
|
31
|
+
- Verified on Node v22.22.3 / macOS 26.6 (arm64). `npm run build` compiles 3 native Swift helpers.
|
|
32
|
+
|
|
33
|
+
## [0.4.1] - 2026-06-11
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `UcuError` base class now has a formal `hint?: string` field with serialization in `toJSON()`. Platform errors that carry remediation hints (e.g. `WindowNotFoundError` from `focusApp` for Electron apps) now pass the hint through the constructor instead of using duck-type property assignment. (Fox 0.3.8 M1)
|
|
38
|
+
- `findElementInputSchema` export annotated with `@internal` — not part of the public API, may change without semver bump. (Raman 0.3.7 M4)
|
|
39
|
+
- `list_windows` empty-result branch now uses the top-level `checkPermission` import instead of a per-call dynamic `import()`. (Fox 0.3.8 M2)
|
|
40
|
+
- `doctor` `tried` arrays are now `readonly string[]` and no longer silently truncated via `.slice(0, 3)`. (Fox 0.3.8 N2/N3)
|
|
41
|
+
- Text-side and value-side regex pre-validation tests consolidated into a single `it.each` parameterized case. (Raman 0.3.7 N2)
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- `find_element` now attaches a pixel-level fallback hint when it returns 0 results AND `scannedCount === 0` for a specific app (meaning the AX tree is empty — common with Electron/Chromium apps). The hint directs the model to use `screenshot` + `ocr` + `click(x, y)` instead of retrying `find_element` forever.
|
|
46
|
+
- CHANGELOG 0.3.7 `prepublishOnly` description now matches the actual script (`npx vitest run tests/unit/ && npm run build` instead of `npm test && npm run build`). (Raman 0.3.7 M2)
|
|
47
|
+
- CHANGELOG 0.3.7 scope claim "corrected in both `src/platform/macos.ts` and this CHANGELOG" narrowed to "corrected in this CHANGELOG" — the source file was already correct. (Raman 0.3.7 M3)
|
|
48
|
+
- CHANGELOG 0.3.5 duplicate "three fewer" / "two fewer" entry cleaned up — only the corrected "two fewer" version remains. (Raman 0.3.7 N4)
|
|
49
|
+
- Test comment "modern V8" clarified to "Node >= 12 / V8" for the all-no-bounds near-sort test. (Raman 0.3.7 N1)
|
|
50
|
+
|
|
51
|
+
### Tests
|
|
52
|
+
|
|
53
|
+
- 225 unit tests pass (13 test files).
|
|
54
|
+
- `macos`: value-side and text-side regex pre-validation tests consolidated into single `it.each` with preserved regression context.
|
|
55
|
+
- Verified on Node v22.22.3 / macOS 26.6 (arm64).
|
|
9
56
|
|
|
10
57
|
### Fixed
|
|
11
58
|
|
|
@@ -43,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
43
90
|
### Fixed
|
|
44
91
|
|
|
45
92
|
- `find_element` value-schema test is no longer a tautology. The 0.3.6 release fixed a *symptom* of the bug (the old test called `handler()` directly, bypassing the McpServer schema-validation wrapper, and then asserted `r.isError === true` which was `undefined`); the underlying tautology remained: the test re-created a local `z.string().min(1).optional()` instead of exercising the real schema. 0.3.7 exports the actual `findElementInputSchema` from `src/mcp/tools.ts` and the test now imports it via `findElementInputSchema.value`, so the assertion genuinely pins the production schema. Pins the 0.3.2 commit `46d4ddd` semantic.
|
|
46
|
-
- CHANGELOG/JXA `textMatches` comment math is now correct: 3 sources → 1 RegExp = **2 fewer** compilations per matched element. The 0.3.5/0.3.6 wording "three fewer" was off by one and has been corrected in
|
|
93
|
+
- CHANGELOG/JXA `textMatches` comment math is now correct: 3 sources → 1 RegExp = **2 fewer** compilations per matched element. The 0.3.5/0.3.6 wording "three fewer" was off by one and has been corrected in this CHANGELOG (the `src/platform/macos.ts` comment was already correct at that point).
|
|
47
94
|
|
|
48
95
|
### Tests
|
|
49
96
|
|
|
@@ -53,7 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
53
100
|
### Hygiene
|
|
54
101
|
|
|
55
102
|
- `findElementInputSchema` is now a named export from `src/mcp/tools.ts` (with a JSDoc comment explaining why the schema is exported) so the unit test can assert the production schema directly instead of constructing a local copy.
|
|
56
|
-
- Added `prepublishOnly` script to `package.json` that runs `
|
|
103
|
+
- Added `prepublishOnly` script to `package.json` that runs `npx vitest run tests/unit/ && npm run build` before `npm publish`. This is a structural guard against the yank rhythm that hit 0.3.3 and 0.3.5: a failed test or build will now block the publish at the npm level, not at the human level. (Raman review Minor #3)
|
|
57
104
|
|
|
58
105
|
## [0.3.5] - 2026-06-06 *(Yanked — see 0.3.6)*
|
|
59
106
|
|
|
@@ -65,7 +112,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
65
112
|
|
|
66
113
|
### Changed
|
|
67
114
|
|
|
68
|
-
- JXA `textMatches` regex branch now compiles the `RegExp` once per element instead of once per source (name / value / description) — three fewer compilations per matched element when `textMode="regex"`. The TS-side pre-validation in `findElement` guarantees the pattern is valid, so the `RegExp` constructor cannot throw here. (Herschel review perf Minor)
|
|
69
115
|
- JXA `textMatches` regex branch now compiles the `RegExp` once per element instead of once per source (name / value / description) — **two** fewer compilations per matched element when `textMode="regex"` (corrected in 0.3.7; 0.3.5/0.3.6 said "three fewer" which was off by one: 3 sources → 1 regex = 2 saved). The TS-side pre-validation in `findElement` guarantees the pattern is valid, so the `RegExp` constructor cannot throw here. (Herschel review perf Minor)
|
|
70
116
|
|
|
71
117
|
### Fixed
|
package/dist/bin/ucu-mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { startServer } from "../src/mcp/server.js";
|
|
3
3
|
import { checkPermissions } from "../src/safety/permissions.js";
|
|
4
|
-
import { MacOSPlatform } from "../src/platform/macos.js";
|
|
4
|
+
import { MacOSPlatform } from "../src/platform/macos/index.js";
|
|
5
5
|
async function runDoctor() {
|
|
6
6
|
const permissions = await checkPermissions();
|
|
7
7
|
const screenLocked = process.platform === "darwin"
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { startServer } from "./mcp/server.js";
|
|
2
|
-
export { ToolRegistry } from "./mcp/tools.js";
|
|
2
|
+
export { ToolRegistry } from "./mcp/tools/index.js";
|
|
3
3
|
export { createStdioTransport } from "./mcp/transport.js";
|
|
4
4
|
export { Platform } from "./platform/base.js";
|
|
5
5
|
export { SafetyGuard } from "./safety/guard.js";
|
|
6
6
|
export { checkPermissions, checkPermission, type PermissionCheckResult, type PermissionType, } from "./safety/permissions.js";
|
|
7
|
-
export { MacOSPlatform } from "./platform/macos.js";
|
|
7
|
+
export { MacOSPlatform } from "./platform/macos/index.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { startServer } from "./mcp/server.js";
|
|
2
|
-
export { ToolRegistry } from "./mcp/tools.js";
|
|
2
|
+
export { ToolRegistry } from "./mcp/tools/index.js";
|
|
3
3
|
export { createStdioTransport } from "./mcp/transport.js";
|
|
4
4
|
export { SafetyGuard } from "./safety/guard.js";
|
|
5
5
|
export { checkPermissions, checkPermission, } from "./safety/permissions.js";
|
|
6
|
-
export { MacOSPlatform } from "./platform/macos.js";
|
|
6
|
+
export { MacOSPlatform } from "./platform/macos/index.js";
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createStdioTransport } from "./transport.js";
|
|
6
|
-
import { registerTools, startUserActivityMonitor } from "./tools.js";
|
|
6
|
+
import { registerTools, startUserActivityMonitor } from "./tools/index.js";
|
|
7
7
|
const UCU_MCP_INSTRUCTIONS = `
|
|
8
8
|
UCU-MCP is a cross-client computer-use server for Claude Code CLI/Desktop, OpenCode, and other MCP clients.
|
|
9
9
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { checkPermission } from "../../safety/permissions.js";
|
|
3
|
+
import { PermissionError } from "../../util/errors.js";
|
|
4
|
+
import { createLogger } from "../../util/logger.js";
|
|
5
|
+
import { metrics } from "../../util/metrics.js";
|
|
6
|
+
import { getPlatform, getActiveTarget, setActiveTarget, withSafety, } from "./helpers.js";
|
|
7
|
+
const log = createLogger("tools");
|
|
8
|
+
export function registerAppTools(registerTool) {
|
|
9
|
+
registerTool("list_apps", "List all running applications", {}, async () => {
|
|
10
|
+
const apps = await withSafety({ action: "list_apps", params: {}, requiresAccessibility: true, execute: async () => getPlatform().listApps() });
|
|
11
|
+
return { content: [{ type: "text", text: JSON.stringify(apps, null, 2) }] };
|
|
12
|
+
});
|
|
13
|
+
registerTool("focus_app", "Select an application/window as the active target context", {
|
|
14
|
+
app: z.string().describe("Application name to focus"),
|
|
15
|
+
}, async (params) => {
|
|
16
|
+
const target = await withSafety({ action: "focus_app", params: {}, requiresAccessibility: true, execute: () => getPlatform().focusApp(params.app) });
|
|
17
|
+
setActiveTarget(target);
|
|
18
|
+
return { content: [{ type: "text", text: JSON.stringify(target, null, 2) }] };
|
|
19
|
+
});
|
|
20
|
+
registerTool("wait", "Wait for a specified duration", { ms: z.number().int().min(1).max(60000).describe("Duration in milliseconds (1–60000)") }, async (params) => {
|
|
21
|
+
await new Promise(r => setTimeout(r, params.ms));
|
|
22
|
+
return { content: [{ type: "text", text: JSON.stringify({ waited: params.ms }) }] };
|
|
23
|
+
});
|
|
24
|
+
registerTool("wait_for_element", "Poll until an accessibility element matching the criteria reaches the desired state", {
|
|
25
|
+
text: z.string().optional().describe("Element text"), role: z.string().optional().describe("Element role"),
|
|
26
|
+
app: z.string().optional().describe("Target app"),
|
|
27
|
+
timeout: z.number().optional().describe("Timeout ms (default 5000)"),
|
|
28
|
+
timeoutMs: z.number().optional().describe("Alias for timeout"),
|
|
29
|
+
interval: z.number().optional().describe("Poll interval ms (default 500)"),
|
|
30
|
+
intervalMs: z.number().optional().describe("Alias for interval"),
|
|
31
|
+
until: z.enum(["appear", "disappear", "value_change"]).default("appear").describe("Wait condition: 'appear' (default) waits for a match, 'disappear' waits until no match, 'value_change' waits until first match's value changes"),
|
|
32
|
+
}, async (params) => {
|
|
33
|
+
const deadline = Date.now() + (params.timeout ?? params.timeoutMs ?? 5000);
|
|
34
|
+
const interval = params.interval ?? params.intervalMs ?? 500;
|
|
35
|
+
const until = params.until ?? "appear";
|
|
36
|
+
const effectiveApp = params.app || getActiveTarget()?.appName;
|
|
37
|
+
const query = { text: params.text, role: params.role, app: effectiveApp, maxResults: 1 };
|
|
38
|
+
const { granted } = await checkPermission("accessibility");
|
|
39
|
+
if (!granted)
|
|
40
|
+
throw new PermissionError("accessibility", process.platform);
|
|
41
|
+
let initialValue;
|
|
42
|
+
let hasInitial = false;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
const response = await getPlatform().findElement(query);
|
|
45
|
+
const matched = response.results[0];
|
|
46
|
+
if (until === "appear") {
|
|
47
|
+
if (matched)
|
|
48
|
+
return { content: [{ type: "text", text: JSON.stringify({ found: true, element: matched }, null, 2) }] };
|
|
49
|
+
}
|
|
50
|
+
else if (until === "disappear") {
|
|
51
|
+
if (!matched)
|
|
52
|
+
return { content: [{ type: "text", text: JSON.stringify({ found: true, reason: "disappeared" }, null, 2) }] };
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (matched) {
|
|
56
|
+
if (!hasInitial) {
|
|
57
|
+
initialValue = matched.value;
|
|
58
|
+
hasInitial = true;
|
|
59
|
+
}
|
|
60
|
+
else if (matched.value !== initialValue) {
|
|
61
|
+
return { content: [{ type: "text", text: JSON.stringify({ found: true, oldValue: initialValue, newValue: matched.value }, null, 2) }] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await new Promise(r => setTimeout(r, interval));
|
|
66
|
+
}
|
|
67
|
+
const reason = until === "value_change" ? (hasInitial ? "value_unchanged" : "never_appeared") : "timeout";
|
|
68
|
+
return { content: [{ type: "text", text: JSON.stringify({ found: false, reason }, null, 2) }] };
|
|
69
|
+
});
|
|
70
|
+
registerTool("doctor", "Check system permissions, native helpers, and client readiness", {}, async () => {
|
|
71
|
+
const { checkPermissions, getPermissionInstructions, getTerminalAppName } = await import("../../safety/permissions.js");
|
|
72
|
+
const { existsSync, statSync } = await import("node:fs");
|
|
73
|
+
const { join, dirname, resolve } = await import("node:path");
|
|
74
|
+
const { fileURLToPath } = await import("node:url");
|
|
75
|
+
const { execFileSync } = await import("node:child_process");
|
|
76
|
+
const permissions = await checkPermissions();
|
|
77
|
+
let screenLocked = false;
|
|
78
|
+
try {
|
|
79
|
+
screenLocked = getPlatform().isScreenLocked?.() ?? false;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Non-darwin platform or uninitialized — screen lock not applicable.
|
|
83
|
+
}
|
|
84
|
+
const termApp = process.platform === "darwin" ? getTerminalAppName() : undefined;
|
|
85
|
+
function resolveHelperPath(relParts) {
|
|
86
|
+
const tried = [];
|
|
87
|
+
const tryPaths = [];
|
|
88
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
89
|
+
const argv1 = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
90
|
+
const argv1Dir = argv1 ? dirname(argv1) : "";
|
|
91
|
+
tryPaths.push(join(process.cwd(), ...relParts));
|
|
92
|
+
if (argv1Dir) {
|
|
93
|
+
tryPaths.push(join(argv1Dir, ...relParts));
|
|
94
|
+
tryPaths.push(join(argv1Dir, "..", ...relParts));
|
|
95
|
+
tryPaths.push(join(argv1Dir, "..", "..", ...relParts));
|
|
96
|
+
}
|
|
97
|
+
tryPaths.push(join(moduleDir, "..", ...relParts));
|
|
98
|
+
tryPaths.push(join(moduleDir, "..", "..", ...relParts));
|
|
99
|
+
tryPaths.push(join(moduleDir, "..", "..", "..", ...relParts));
|
|
100
|
+
tryPaths.push(join(moduleDir, "..", "..", "..", "..", ...relParts));
|
|
101
|
+
if (process.platform === "darwin") {
|
|
102
|
+
try {
|
|
103
|
+
const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8", timeout: 2000 }).trim();
|
|
104
|
+
if (npmRoot) {
|
|
105
|
+
tryPaths.push(join(npmRoot, "ucu-mcp", ...relParts));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* npm not on PATH is fine */ }
|
|
109
|
+
}
|
|
110
|
+
for (const p of tryPaths) {
|
|
111
|
+
tried.push(p);
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(p) && statSync(p).isFile())
|
|
114
|
+
return { path: p, tried };
|
|
115
|
+
}
|
|
116
|
+
catch { /* skip */ }
|
|
117
|
+
}
|
|
118
|
+
return { path: null, tried };
|
|
119
|
+
}
|
|
120
|
+
let nativeHelpers;
|
|
121
|
+
if (process.platform === "darwin") {
|
|
122
|
+
const cgevent = resolveHelperPath(["native", "cgevent", "cgevent-helper"]);
|
|
123
|
+
const ocr = resolveHelperPath(["native", "ocr", "ocr-helper"]);
|
|
124
|
+
const windowlist = resolveHelperPath(["native", "windowlist", "windowlist-helper"]);
|
|
125
|
+
nativeHelpers = {
|
|
126
|
+
cgevent: { ok: cgevent.path !== null, path: cgevent.path, tried: cgevent.tried },
|
|
127
|
+
ocr: { ok: ocr.path !== null, path: ocr.path, tried: ocr.tried },
|
|
128
|
+
windowlist: { ok: windowlist.path !== null, path: windowlist.path, tried: windowlist.tried },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
let readiness = "ready";
|
|
132
|
+
const issues = [];
|
|
133
|
+
if (!permissions.granted) {
|
|
134
|
+
readiness = "blocked";
|
|
135
|
+
for (const m of (permissions.missing ?? [])) {
|
|
136
|
+
issues.push(`Missing macOS permission: ${m}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (screenLocked) {
|
|
140
|
+
readiness = "blocked";
|
|
141
|
+
issues.push("Screen is locked");
|
|
142
|
+
}
|
|
143
|
+
if (process.platform === "darwin" && nativeHelpers) {
|
|
144
|
+
if (!nativeHelpers.cgevent.ok) {
|
|
145
|
+
readiness = readiness === "ready" ? "degraded" : readiness;
|
|
146
|
+
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.");
|
|
147
|
+
}
|
|
148
|
+
if (!nativeHelpers.ocr.ok) {
|
|
149
|
+
readiness = readiness === "ready" ? "degraded" : readiness;
|
|
150
|
+
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.");
|
|
151
|
+
}
|
|
152
|
+
if (!nativeHelpers.windowlist.ok) {
|
|
153
|
+
readiness = readiness === "ready" ? "degraded" : readiness;
|
|
154
|
+
issues.push("Native windowlist helper not found (window enumeration will fall back to slow JXA). Run `npm run build` to compile it.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
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. Pixel-level workaround: use screenshot + ocr to locate UI elements by text, then click(x, y) at the detected bounding box coordinates. Alternatively, modify the app\'s config file or database directly.";
|
|
158
|
+
const clients = {};
|
|
159
|
+
for (const bin of ["claude", "codex", "opencode", "npx"]) {
|
|
160
|
+
try {
|
|
161
|
+
const path = execFileSync("which", [bin], { encoding: "utf-8", timeout: 2000 }).trim();
|
|
162
|
+
clients[bin] = path || "not found";
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
clients[bin] = "not found";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const recommendations = [];
|
|
169
|
+
if (readiness === "blocked") {
|
|
170
|
+
for (const m of (permissions.missing ?? [])) {
|
|
171
|
+
const app = termApp ?? "your terminal app";
|
|
172
|
+
recommendations.push(`${m}: ${getPermissionInstructions(m)} (Grant to ${app}.)`);
|
|
173
|
+
}
|
|
174
|
+
if (screenLocked)
|
|
175
|
+
recommendations.push("Unlock the screen, then retry.");
|
|
176
|
+
}
|
|
177
|
+
if (readiness !== "ready") {
|
|
178
|
+
if (process.platform === "darwin" && nativeHelpers && (!nativeHelpers.cgevent.ok || !nativeHelpers.ocr.ok)) {
|
|
179
|
+
recommendations.push("Run `npm run build` in the ucu-mcp project to compile native Swift helpers (cgevent-helper, ocr-helper, windowlist-helper).");
|
|
180
|
+
}
|
|
181
|
+
if (process.platform === "darwin" && nativeHelpers && !nativeHelpers.windowlist.ok) {
|
|
182
|
+
recommendations.push("windowlist helper missing — list_windows will fall back to JXA (~3-6s, unreliable for Electron). Run `npm run build`.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (readiness === "ready") {
|
|
186
|
+
recommendations.push("All checks passed. MCP client can proceed with automation.");
|
|
187
|
+
}
|
|
188
|
+
else if (process.platform === "darwin") {
|
|
189
|
+
recommendations.push(electronHint);
|
|
190
|
+
}
|
|
191
|
+
const report = {
|
|
192
|
+
readiness,
|
|
193
|
+
issues: issues.length > 0 ? issues : undefined,
|
|
194
|
+
recommendations,
|
|
195
|
+
platform: process.platform,
|
|
196
|
+
node: process.version,
|
|
197
|
+
permissions,
|
|
198
|
+
screenLocked,
|
|
199
|
+
terminalApp: termApp,
|
|
200
|
+
nativeHelpers,
|
|
201
|
+
clients,
|
|
202
|
+
safety: {
|
|
203
|
+
urlBlocklist: true,
|
|
204
|
+
lockScreenGuard: process.platform === "darwin",
|
|
205
|
+
typedTextInjectionScan: true,
|
|
206
|
+
},
|
|
207
|
+
stdioCommand: "ucu-mcp",
|
|
208
|
+
metrics: {
|
|
209
|
+
global: metrics.stats(),
|
|
210
|
+
byTool: metrics.byTool(),
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
214
|
+
});
|
|
215
|
+
registerTool("clipboard_read", "Read the current contents of the system clipboard", {}, async () => {
|
|
216
|
+
const text = await withSafety({ action: "clipboard_read", params: {}, execute: () => getPlatform().readClipboard() });
|
|
217
|
+
return { content: [{ type: "text", text: JSON.stringify({ text }, null, 2) }] };
|
|
218
|
+
});
|
|
219
|
+
registerTool("clipboard_write", "Write text to the system clipboard (text injection patterns are blocked)", {
|
|
220
|
+
text: z.string().describe("Text to place on the clipboard"),
|
|
221
|
+
}, async (params) => {
|
|
222
|
+
await withSafety({ action: "clipboard_write", params: { text: params.text }, execute: () => getPlatform().writeClipboard(params.text) });
|
|
223
|
+
return { content: [{ type: "text", text: JSON.stringify({ written: true }, null, 2) }] };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type RegisterToolFn } from "./helpers.js";
|
|
3
|
+
export declare const findElementInputSchema: {
|
|
4
|
+
text: z.ZodOptional<z.ZodString>;
|
|
5
|
+
role: z.ZodOptional<z.ZodString>;
|
|
6
|
+
app: z.ZodOptional<z.ZodString>;
|
|
7
|
+
depth: z.ZodOptional<z.ZodNumber>;
|
|
8
|
+
includeBounds: z.ZodDefault<z.ZodBoolean>;
|
|
9
|
+
maxResults: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
textMode: z.ZodDefault<z.ZodEnum<{
|
|
11
|
+
contains: "contains";
|
|
12
|
+
exact: "exact";
|
|
13
|
+
regex: "regex";
|
|
14
|
+
}>>;
|
|
15
|
+
visibleOnly: z.ZodDefault<z.ZodBoolean>;
|
|
16
|
+
value: z.ZodOptional<z.ZodString>;
|
|
17
|
+
index: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
near: z.ZodOptional<z.ZodObject<{
|
|
19
|
+
x: z.ZodNumber;
|
|
20
|
+
y: z.ZodNumber;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
};
|
|
23
|
+
export declare function registerElementTools(registerTool: RegisterToolFn): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getPlatform, getActiveTarget, getSafetyContext, withSafety, actionResponse, captureAfterFields, } from "./helpers.js";
|
|
3
|
+
export const findElementInputSchema = {
|
|
4
|
+
text: z.string().optional().describe("Text to search"),
|
|
5
|
+
role: z.string().optional().describe("AX role"),
|
|
6
|
+
app: z.string().optional().describe("Target app"),
|
|
7
|
+
depth: z.number().optional().describe("AX tree depth"),
|
|
8
|
+
includeBounds: z.boolean().default(true).describe("Include bounds"),
|
|
9
|
+
maxResults: z.number().min(1).max(200).default(50).describe("Max results"),
|
|
10
|
+
textMode: z.enum(["contains", "exact", "regex"]).default("contains").describe("Text matching mode: contains (default), exact, or regex"),
|
|
11
|
+
visibleOnly: z.boolean().default(false).describe("Only return elements with valid on-screen bounds"),
|
|
12
|
+
value: z.string().min(1).optional().describe("Filter by AX element value (text/regex/exact, see textMode). Empty string is treated as unset (omit the field instead)."),
|
|
13
|
+
index: z.number().int().nonnegative().optional().describe("Return only the Nth match (0-based) after all other filtering and sorting"),
|
|
14
|
+
near: z.object({ x: z.number(), y: z.number() }).optional().describe("Sort results by ascending distance to this point and return closest first"),
|
|
15
|
+
};
|
|
16
|
+
export function registerElementTools(registerTool) {
|
|
17
|
+
registerTool("find_element", "Find accessibility elements by text, role, or value. Supports value/index/near selectors.", findElementInputSchema, async (params) => {
|
|
18
|
+
const effectiveApp = params.app || getActiveTarget()?.appName;
|
|
19
|
+
const safetyCtx = await getSafetyContext(undefined);
|
|
20
|
+
const response = await withSafety({ action: "find_element", params: { ...safetyCtx }, requiresAccessibility: true,
|
|
21
|
+
execute: () => getPlatform().findElement({ text: params.text, role: params.role, app: effectiveApp, depth: params.depth, includeBounds: params.includeBounds, maxResults: params.maxResults, textMode: params.textMode, visibleOnly: params.visibleOnly, value: params.value, index: params.index, near: params.near }) });
|
|
22
|
+
const payload = { results: response.results, metrics: response.metrics };
|
|
23
|
+
if (response.results.length === 0 && effectiveApp && response.metrics.scannedCount === 0) {
|
|
24
|
+
payload.hint =
|
|
25
|
+
`${effectiveApp} returned 0 AX elements (scannedCount=0, meaning the AX tree is empty). ` +
|
|
26
|
+
"This is typical for Electron/Chromium apps whose AX tree is not exposed to System Events. " +
|
|
27
|
+
"Pixel-level workaround: call screenshot to capture the screen, then ocr to locate " +
|
|
28
|
+
"the target UI text and get its bounding box coordinates, then click(x, y) at those " +
|
|
29
|
+
"screen coordinates. Alternatively, use type_text or press_key for keyboard-based " +
|
|
30
|
+
"interaction, or modify the app's config file or database directly.";
|
|
31
|
+
}
|
|
32
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
33
|
+
});
|
|
34
|
+
registerTool("click_element", "Click an accessibility element by its ID", {
|
|
35
|
+
elementId: z.string().describe("AX element identifier"), app: z.string().optional().describe("Target app"), ...captureAfterFields,
|
|
36
|
+
}, async (params) => {
|
|
37
|
+
const effectiveApp = params.app || getActiveTarget()?.appName;
|
|
38
|
+
const safetyCtx = await getSafetyContext();
|
|
39
|
+
await withSafety({ action: "click_element", params: { ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().clickElement(params.elementId, effectiveApp) });
|
|
40
|
+
return actionResponse("click_element", { clicked: true, elementId: params.elementId }, { elementId: params.elementId, app: effectiveApp }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
41
|
+
});
|
|
42
|
+
registerTool("set_value", "Set the value of an accessibility element", {
|
|
43
|
+
elementId: z.string().describe("AX element identifier"), value: z.string().describe("Value to set"), app: z.string().optional().describe("Target app"), ...captureAfterFields,
|
|
44
|
+
}, async (params) => {
|
|
45
|
+
const effectiveApp = params.app || getActiveTarget()?.appName;
|
|
46
|
+
const safetyCtx = await getSafetyContext();
|
|
47
|
+
await withSafety({ action: "set_value", params: { value: params.value, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().setElementValue(params.elementId, params.value, effectiveApp) });
|
|
48
|
+
return actionResponse("set_value", { setValue: true, elementId: params.elementId }, { elementId: params.elementId, app: effectiveApp }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
49
|
+
});
|
|
50
|
+
registerTool("type_in_element", "Type text into an accessibility element, optionally clearing first", {
|
|
51
|
+
elementId: z.string().describe("AX element identifier"), text: z.string().describe("Text to type"),
|
|
52
|
+
app: z.string().optional().describe("Target app"), clearFirst: z.boolean().optional().describe("Clear existing text before typing"), ...captureAfterFields,
|
|
53
|
+
}, async (params) => {
|
|
54
|
+
const effectiveApp = params.app || getActiveTarget()?.appName;
|
|
55
|
+
const safetyCtx = await getSafetyContext();
|
|
56
|
+
await withSafety({ action: "type_in_element", params: { text: params.text, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().typeInElement(params.elementId, params.text, effectiveApp, params.clearFirst) });
|
|
57
|
+
return actionResponse("type_in_element", { typed: true, elementId: params.elementId, charCount: params.text.length }, { elementId: params.elementId, app: effectiveApp }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Platform, AppTarget } from "../../platform/base.js";
|
|
3
|
+
import { SafetyGuard } from "../../safety/guard.js";
|
|
4
|
+
export declare function getPlatform(): Platform;
|
|
5
|
+
/** @internal Test-only injection point. */
|
|
6
|
+
export declare function __setPlatformForTesting(platform: Platform | undefined): void;
|
|
7
|
+
export declare const safety: SafetyGuard;
|
|
8
|
+
export declare function getActiveTarget(): AppTarget | undefined;
|
|
9
|
+
export declare function setActiveTarget(target: AppTarget): void;
|
|
10
|
+
export declare const captureAfterFields: {
|
|
11
|
+
captureAfter: z.ZodDefault<z.ZodBoolean>;
|
|
12
|
+
captureMaxWidth: z.ZodDefault<z.ZodNumber>;
|
|
13
|
+
captureFormat: z.ZodDefault<z.ZodEnum<{
|
|
14
|
+
png: "png";
|
|
15
|
+
jpeg: "jpeg";
|
|
16
|
+
}>>;
|
|
17
|
+
};
|
|
18
|
+
export declare function resolvePoint(x: number, y: number, windowId?: string): Promise<{
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function getSafetyContext(windowId?: string): Promise<{
|
|
23
|
+
windowTitle?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
}>;
|
|
26
|
+
export type ToolContent = {
|
|
27
|
+
type: "text";
|
|
28
|
+
text: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "image";
|
|
31
|
+
data: string;
|
|
32
|
+
mimeType: string;
|
|
33
|
+
};
|
|
34
|
+
export type ToolResult = {
|
|
35
|
+
content: ToolContent[];
|
|
36
|
+
isError?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export declare function jsonText(value: unknown): ToolContent;
|
|
39
|
+
export declare function recoveryHint(code: string): string;
|
|
40
|
+
export declare function errorDetails(error: unknown): Record<string, unknown>;
|
|
41
|
+
export interface ActionReceipt {
|
|
42
|
+
actionId: string;
|
|
43
|
+
action: string;
|
|
44
|
+
status: "ok" | "partial" | "blocked";
|
|
45
|
+
target: {
|
|
46
|
+
app?: string;
|
|
47
|
+
windowId?: string;
|
|
48
|
+
elementId?: string;
|
|
49
|
+
x?: number;
|
|
50
|
+
y?: number;
|
|
51
|
+
startX?: number;
|
|
52
|
+
startY?: number;
|
|
53
|
+
endX?: number;
|
|
54
|
+
endY?: number;
|
|
55
|
+
};
|
|
56
|
+
result: Record<string, unknown>;
|
|
57
|
+
capture: {
|
|
58
|
+
requested: boolean;
|
|
59
|
+
status: "ok" | "skipped" | "error";
|
|
60
|
+
format?: string;
|
|
61
|
+
maxWidth?: number;
|
|
62
|
+
error?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
warnings: string[];
|
|
65
|
+
next: string;
|
|
66
|
+
}
|
|
67
|
+
export declare function buildActionReceipt(action: string, status: ActionReceipt["status"], target: ActionReceipt["target"], result: Record<string, unknown>, captureRequested: boolean, captureFormat?: string, captureMaxWidth?: number, captureError?: Record<string, unknown>, warnings?: string[]): ActionReceipt;
|
|
68
|
+
export declare function mcpErrorResponse(error: unknown): ToolResult;
|
|
69
|
+
export declare function actionResponse(action: string, result: Record<string, unknown>, target: ActionReceipt["target"], captureAfter?: boolean, captureFormat?: "png" | "jpeg", captureMaxWidth?: number, warnings?: string[]): Promise<{
|
|
70
|
+
content: ToolContent[];
|
|
71
|
+
}>;
|
|
72
|
+
export interface SafetyAction {
|
|
73
|
+
action: string;
|
|
74
|
+
params: Record<string, unknown>;
|
|
75
|
+
requiresAccessibility?: boolean;
|
|
76
|
+
requiresScreenRecording?: boolean;
|
|
77
|
+
skipUserActivityPause?: boolean;
|
|
78
|
+
dryRun?: () => Promise<string>;
|
|
79
|
+
execute: () => Promise<unknown>;
|
|
80
|
+
}
|
|
81
|
+
export type RegisterToolFn = (name: string, description: string, schema: Record<string, z.ZodTypeAny>, handler: (params: any) => Promise<ToolResult>) => void;
|
|
82
|
+
export declare function withSafety<T>(sa: SafetyAction): Promise<T>;
|
|
83
|
+
export declare function startUserActivityMonitor(): void;
|
|
84
|
+
export declare function stopUserActivityMonitor(): void;
|