axbridge-mcp 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/bridge.js +67 -0
- package/dist/build.js +32 -0
- package/dist/cli.js +161 -0
- package/dist/devices.js +51 -0
- package/dist/exec.js +28 -0
- package/dist/images.js +50 -0
- package/dist/index.js +369 -0
- package/dist/version.js +60 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johannes Grof
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# axbridge-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for [axbridge](https://github.com/jx-grxf/axbridge): lets Claude Code, Codex,
|
|
4
|
+
Cursor and other MCP clients use an iOS app in the Simulator as text. It reads the screen
|
|
5
|
+
from the accessibility tree, taps by name, types, scrolls and goes back, and manages
|
|
6
|
+
simulators, builds, screenshots, recordings and visual diffs.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
claude mcp add axbridge -- npx -y axbridge-mcp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The app under test needs the AXBridge Swift package and one call to `AXBridge.start()`.
|
|
13
|
+
Setup, the full tool list and measurements are in the
|
|
14
|
+
[main README](https://github.com/jx-grxf/axbridge#readme).
|
|
15
|
+
|
|
16
|
+
Requires macOS with Xcode and Node.js 20+. MIT licensed.
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CommandError } from "./exec.js";
|
|
7
|
+
const STATE_DIR = join(homedir(), ".cache", "axbridge");
|
|
8
|
+
const STATE_FILE = join(STATE_DIR, "sessions.json");
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
let loaded = false;
|
|
11
|
+
async function load() {
|
|
12
|
+
if (loaded)
|
|
13
|
+
return;
|
|
14
|
+
loaded = true;
|
|
15
|
+
try {
|
|
16
|
+
const saved = JSON.parse(await readFile(STATE_FILE, "utf8"));
|
|
17
|
+
for (const s of saved)
|
|
18
|
+
sessions.set(s.udid, s);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// first run
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function persist() {
|
|
25
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
26
|
+
await writeFile(STATE_FILE, JSON.stringify([...sessions.values()], null, 2));
|
|
27
|
+
await chmod(STATE_FILE, 0o600);
|
|
28
|
+
}
|
|
29
|
+
export function freePort() {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const server = createServer();
|
|
32
|
+
server.once("error", reject);
|
|
33
|
+
server.listen(0, "127.0.0.1", () => {
|
|
34
|
+
const address = server.address();
|
|
35
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
36
|
+
server.close(() => resolve(port));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export async function newSession(udid, bundleId) {
|
|
41
|
+
await load();
|
|
42
|
+
const session = { udid, bundleId, port: await freePort(), token: randomBytes(24).toString("hex") };
|
|
43
|
+
sessions.set(udid, session);
|
|
44
|
+
await persist();
|
|
45
|
+
return session;
|
|
46
|
+
}
|
|
47
|
+
export async function sessionFor(udid) {
|
|
48
|
+
await load();
|
|
49
|
+
const session = sessions.get(udid);
|
|
50
|
+
if (!session)
|
|
51
|
+
throw new CommandError("The app was not launched through axbridge on this simulator. Call `launch` first.");
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
export async function call(session, method, path, body = "", timeoutMs = 15_000) {
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(`http://127.0.0.1:${session.port}${path}`, {
|
|
57
|
+
method,
|
|
58
|
+
body: method === "POST" ? body : undefined,
|
|
59
|
+
headers: { "X-AXBridge-Token": session.token, "Content-Type": "text/plain; charset=utf-8" },
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
});
|
|
62
|
+
return { status: response.status, text: await response.text() };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new CommandError(`The bridge in the app does not answer (${error.message}). Is it a Debug build with AXBridge.start(), and was it launched with \`launch\`?`);
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { run, CommandError } from "./exec.js";
|
|
6
|
+
export async function buildForSimulator(opts) {
|
|
7
|
+
if (!opts.project && !opts.workspace)
|
|
8
|
+
throw new CommandError("Pass project (.xcodeproj) or workspace (.xcworkspace).");
|
|
9
|
+
const container = resolve(opts.workspace ?? opts.project);
|
|
10
|
+
const key = createHash("sha1").update(`${container}|${opts.scheme}`).digest("hex").slice(0, 12);
|
|
11
|
+
const derived = join(homedir(), ".cache", "axbridge", "DerivedData", key);
|
|
12
|
+
await run("xcodebuild", [
|
|
13
|
+
opts.workspace ? "-workspace" : "-project", container,
|
|
14
|
+
"-scheme", opts.scheme,
|
|
15
|
+
"-configuration", opts.configuration,
|
|
16
|
+
"-destination", `id=${opts.udid}`,
|
|
17
|
+
"-derivedDataPath", derived,
|
|
18
|
+
"build",
|
|
19
|
+
], { timeoutMs: 30 * 60_000 });
|
|
20
|
+
const products = join(derived, "Build", "Products", `${opts.configuration}-iphonesimulator`);
|
|
21
|
+
const apps = (await readdir(products)).filter((f) => f.endsWith(".app"));
|
|
22
|
+
if (apps.length === 0)
|
|
23
|
+
throw new CommandError(`No .app in ${products}`);
|
|
24
|
+
// A scheme can build extensions or test hosts next to the app; the scheme's own app
|
|
25
|
+
// usually carries its name.
|
|
26
|
+
const app = apps.find((a) => a.replace(/\.app$/, "") === opts.scheme) ?? apps[0];
|
|
27
|
+
const appPath = join(products, app);
|
|
28
|
+
return { appPath, bundleId: await bundleIdOf(appPath) };
|
|
29
|
+
}
|
|
30
|
+
export async function bundleIdOf(appPath) {
|
|
31
|
+
return run("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleIdentifier", join(appPath, "Info.plist")]);
|
|
32
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
import { listDevices, resolveDevice } from "./devices.js";
|
|
7
|
+
import { sessionFor, call } from "./bridge.js";
|
|
8
|
+
import { VERSION, newerVersion } from "./version.js";
|
|
9
|
+
const HELP = `axbridge ${VERSION}: drive iOS Simulator apps as text from coding agents
|
|
10
|
+
|
|
11
|
+
Usage
|
|
12
|
+
npx axbridge-mcp start the MCP server (stdio); this is what agents run
|
|
13
|
+
npx axbridge-mcp setup <agent> register the MCP server: claude, codex, cursor or all
|
|
14
|
+
(cursor: add --project for .cursor/mcp.json in this folder)
|
|
15
|
+
npx axbridge-mcp doctor check Xcode, simulators, Node and the installed version
|
|
16
|
+
npx axbridge-mcp screen [device] print the current screen of an app launched by axbridge
|
|
17
|
+
npx axbridge-mcp --version
|
|
18
|
+
|
|
19
|
+
Docs: https://github.com/jx-grxf/axbridge`;
|
|
20
|
+
const ok = (s) => console.log(` ✓ ${s}`);
|
|
21
|
+
const bad = (s) => console.log(` ✗ ${s}`);
|
|
22
|
+
const SERVER = { command: "npx", args: ["-y", "axbridge-mcp"] };
|
|
23
|
+
async function has(cmd) {
|
|
24
|
+
return run("/usr/bin/which", [cmd]).then(() => true, () => false);
|
|
25
|
+
}
|
|
26
|
+
async function doctor() {
|
|
27
|
+
let failures = 0;
|
|
28
|
+
console.log(`axbridge ${VERSION}`);
|
|
29
|
+
if (platform() === "darwin")
|
|
30
|
+
ok("macOS");
|
|
31
|
+
else {
|
|
32
|
+
bad("macOS is required (the iOS Simulator only runs there)");
|
|
33
|
+
failures++;
|
|
34
|
+
}
|
|
35
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
36
|
+
if (major >= 20)
|
|
37
|
+
ok(`Node.js ${process.versions.node}`);
|
|
38
|
+
else {
|
|
39
|
+
bad(`Node.js ${process.versions.node}; 20 or newer is required`);
|
|
40
|
+
failures++;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
ok((await run("xcodebuild", ["-version"])).split("\n")[0]);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
bad("xcodebuild not found; install Xcode and run `sudo xcode-select -s /Applications/Xcode.app`");
|
|
47
|
+
failures++;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const devices = await listDevices();
|
|
51
|
+
const booted = devices.filter((d) => d.state === "Booted");
|
|
52
|
+
ok(`simctl: ${devices.filter((d) => d.available).length} simulators, ${booted.length} booted${booted.length ? ` (${booted.map((d) => d.name).join(", ")})` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
bad(`xcrun simctl failed: ${error.message}`);
|
|
56
|
+
failures++;
|
|
57
|
+
}
|
|
58
|
+
if (await has("sips"))
|
|
59
|
+
ok("sips (screenshots)");
|
|
60
|
+
else {
|
|
61
|
+
bad("sips missing; screenshots will fail");
|
|
62
|
+
failures++;
|
|
63
|
+
}
|
|
64
|
+
const latest = await newerVersion(3000);
|
|
65
|
+
if (latest)
|
|
66
|
+
bad(`axbridge ${latest} is available; npx picks it up with \`npx -y axbridge-mcp@latest\``);
|
|
67
|
+
else
|
|
68
|
+
ok("up to date (or offline)");
|
|
69
|
+
return failures ? 1 : 0;
|
|
70
|
+
}
|
|
71
|
+
async function setupClaude() {
|
|
72
|
+
if (!(await has("claude"))) {
|
|
73
|
+
bad("claude CLI not found; run: claude mcp add axbridge --scope user -- npx -y axbridge-mcp");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await run("claude", ["mcp", "remove", "axbridge", "--scope", "user"]).catch(() => undefined);
|
|
77
|
+
await run("claude", ["mcp", "add", "axbridge", "--scope", "user", "--", ...[SERVER.command, ...SERVER.args]]);
|
|
78
|
+
ok("Claude Code: added for your user (all projects)");
|
|
79
|
+
}
|
|
80
|
+
async function setupCodex() {
|
|
81
|
+
const file = join(homedir(), ".codex", "config.toml");
|
|
82
|
+
const current = existsSync(file) ? await readFile(file, "utf8") : "";
|
|
83
|
+
if (/^\[mcp_servers\.axbridge\]/m.test(current)) {
|
|
84
|
+
ok(`Codex: already in ${file}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await mkdir(dirname(file), { recursive: true });
|
|
88
|
+
const block = `\n[mcp_servers.axbridge]\ncommand = "npx"\nargs = ["-y", "axbridge-mcp"]\n`;
|
|
89
|
+
await writeFile(file, current.replace(/\n*$/, "\n") + block);
|
|
90
|
+
ok(`Codex: added to ${file}`);
|
|
91
|
+
}
|
|
92
|
+
async function setupCursor(project) {
|
|
93
|
+
const file = project ? join(process.cwd(), ".cursor", "mcp.json") : join(homedir(), ".cursor", "mcp.json");
|
|
94
|
+
let config = {};
|
|
95
|
+
if (existsSync(file)) {
|
|
96
|
+
try {
|
|
97
|
+
config = JSON.parse(await readFile(file, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
bad(`Cursor: ${file} is not valid JSON, left unchanged`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
config.mcpServers = { ...(config.mcpServers ?? {}), axbridge: SERVER };
|
|
105
|
+
await mkdir(dirname(file), { recursive: true });
|
|
106
|
+
await writeFile(file, JSON.stringify(config, null, 2) + "\n");
|
|
107
|
+
ok(`Cursor: written to ${file}`);
|
|
108
|
+
}
|
|
109
|
+
async function setup(agent, flags) {
|
|
110
|
+
const targets = agent === "all" ? ["claude", "codex", "cursor"] : agent ? [agent] : [];
|
|
111
|
+
if (targets.length === 0 || targets.some((t) => !["claude", "codex", "cursor"].includes(t))) {
|
|
112
|
+
console.log("Usage: npx axbridge-mcp setup claude|codex|cursor|all [--project]");
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
for (const t of targets) {
|
|
116
|
+
if (t === "claude")
|
|
117
|
+
await setupClaude();
|
|
118
|
+
if (t === "codex")
|
|
119
|
+
await setupCodex();
|
|
120
|
+
if (t === "cursor")
|
|
121
|
+
await setupCursor(flags.includes("--project"));
|
|
122
|
+
}
|
|
123
|
+
console.log("\nNext: add the AXBridge Swift package to your app and call AXBridge.start(). See the README.");
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
async function screen(device) {
|
|
127
|
+
const d = await resolveDevice(device);
|
|
128
|
+
const reply = await call(await sessionFor(d.udid), "GET", "/screen");
|
|
129
|
+
console.log(reply.text);
|
|
130
|
+
return reply.status === 200 ? 0 : 1;
|
|
131
|
+
}
|
|
132
|
+
export async function runCli(args) {
|
|
133
|
+
const [command, ...rest] = args;
|
|
134
|
+
try {
|
|
135
|
+
switch (command) {
|
|
136
|
+
case "--version":
|
|
137
|
+
case "-v":
|
|
138
|
+
case "version":
|
|
139
|
+
console.log(VERSION);
|
|
140
|
+
return 0;
|
|
141
|
+
case "doctor":
|
|
142
|
+
return await doctor();
|
|
143
|
+
case "setup":
|
|
144
|
+
return await setup(rest.find((a) => !a.startsWith("-")), rest);
|
|
145
|
+
case "screen":
|
|
146
|
+
return await screen(rest[0]);
|
|
147
|
+
case "help":
|
|
148
|
+
case "--help":
|
|
149
|
+
case "-h":
|
|
150
|
+
console.log(HELP);
|
|
151
|
+
return 0;
|
|
152
|
+
default:
|
|
153
|
+
console.log(HELP);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
console.error(error.message);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/devices.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { simctl, CommandError } from "./exec.js";
|
|
2
|
+
export async function listDevices() {
|
|
3
|
+
const raw = JSON.parse(await simctl(["list", "devices", "--json"]));
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const [runtimeId, list] of Object.entries(raw.devices)) {
|
|
6
|
+
const runtime = runtimeId.replace("com.apple.CoreSimulator.SimRuntime.", "").replace(/-(\d+)-(\d+)$/, " $1.$2");
|
|
7
|
+
for (const d of list) {
|
|
8
|
+
out.push({ udid: d.udid, name: d.name, state: d.state, runtime, available: d.isAvailable });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
const UDID = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
|
14
|
+
/** Pure resolution so it can be tested: "booted", a UDID, or an exact (then partial) name. */
|
|
15
|
+
export function pickDevice(devices, query) {
|
|
16
|
+
const q = (query ?? "booted").trim();
|
|
17
|
+
if (q === "booted") {
|
|
18
|
+
const booted = devices.filter((d) => d.state === "Booted");
|
|
19
|
+
if (booted.length === 1)
|
|
20
|
+
return booted[0];
|
|
21
|
+
if (booted.length === 0)
|
|
22
|
+
throw new CommandError("No simulator is booted. Use `boot` with a device name, or `devices` to list them.");
|
|
23
|
+
throw new CommandError(`${booted.length} simulators are booted; pass device as one of: ${booted.map((d) => `"${d.name}"`).join(", ")}`);
|
|
24
|
+
}
|
|
25
|
+
if (UDID.test(q)) {
|
|
26
|
+
const hit = devices.find((d) => d.udid.toLowerCase() === q.toLowerCase());
|
|
27
|
+
if (hit)
|
|
28
|
+
return hit;
|
|
29
|
+
throw new CommandError(`No simulator with UDID ${q}`);
|
|
30
|
+
}
|
|
31
|
+
const available = devices.filter((d) => d.available);
|
|
32
|
+
const exact = available.filter((d) => d.name === q);
|
|
33
|
+
const pool = exact.length ? exact : available.filter((d) => d.name.toLowerCase().includes(q.toLowerCase()));
|
|
34
|
+
if (pool.length === 0)
|
|
35
|
+
throw new CommandError(`No simulator named "${q}". Use \`devices\` to list them.`);
|
|
36
|
+
// Several runtimes can carry the same name; prefer a booted one, then the newest runtime.
|
|
37
|
+
const ranked = [...pool].sort((a, b) => Number(b.state === "Booted") - Number(a.state === "Booted") || b.runtime.localeCompare(a.runtime, undefined, { numeric: true }));
|
|
38
|
+
return ranked[0];
|
|
39
|
+
}
|
|
40
|
+
export async function resolveDevice(query) {
|
|
41
|
+
return pickDevice(await listDevices(), query);
|
|
42
|
+
}
|
|
43
|
+
export async function ensureBooted(device) {
|
|
44
|
+
if (device.state !== "Booted") {
|
|
45
|
+
await simctl(["boot", device.udid]).catch((e) => {
|
|
46
|
+
if (!/current state: Booted/.test(e.message))
|
|
47
|
+
throw e;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
await simctl(["bootstatus", device.udid, "-b"], { timeoutMs: 180_000 });
|
|
51
|
+
}
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
export class CommandError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export function run(cmd, args, opts = {}) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
execFile(cmd, args, { env: opts.env ?? process.env, timeout: opts.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, cwd: opts.cwd }, (error, stdout, stderr) => {
|
|
7
|
+
if (error) {
|
|
8
|
+
const output = `${stdout ?? ""}\n${stderr ?? ""}`;
|
|
9
|
+
// xcodebuild buries the cause under hundreds of lines; the error: lines are what matters.
|
|
10
|
+
const errors = [...new Set(output.split("\n").map((l) => l.trim()).filter((l) => /^\/.*: error: /.test(l)))];
|
|
11
|
+
const detail = errors.length
|
|
12
|
+
? errors.slice(0, 10).join("\n")
|
|
13
|
+
: (stderr || stdout || error.message).toString().trim().split("\n").slice(-12).join("\n");
|
|
14
|
+
reject(new CommandError(`${cmd} ${args.slice(0, 3).join(" ")}: ${detail}`));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
resolve(stdout.toString().trim());
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function simctl(args, opts = {}) {
|
|
23
|
+
return run("xcrun", ["simctl", ...args], opts);
|
|
24
|
+
}
|
|
25
|
+
export function background(cmd, args) {
|
|
26
|
+
return spawn(cmd, args, { stdio: "ignore" });
|
|
27
|
+
}
|
|
28
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { PNG } from "pngjs";
|
|
5
|
+
import pixelmatch from "pixelmatch";
|
|
6
|
+
import { run, simctl } from "./exec.js";
|
|
7
|
+
export const OUTPUT_DIR = process.env.AXBRIDGE_OUTPUT_DIR ?? join(tmpdir(), "axbridge");
|
|
8
|
+
function stamp() {
|
|
9
|
+
const time = new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "").replace("T", "-");
|
|
10
|
+
return `${time}-${Math.random().toString(36).slice(2, 6)}`;
|
|
11
|
+
}
|
|
12
|
+
export async function screenshot(udid, opts) {
|
|
13
|
+
await mkdir(OUTPUT_DIR, { recursive: true });
|
|
14
|
+
const stem = (opts.name ?? stamp()).replace(/[^\w.-]+/g, "-");
|
|
15
|
+
const full = join(OUTPUT_DIR, `${stem}.png`);
|
|
16
|
+
await simctl(["io", udid, "screenshot", "--type=png", full]);
|
|
17
|
+
let source = full;
|
|
18
|
+
if (opts.crop) {
|
|
19
|
+
const { x, y, w, h, scale } = opts.crop;
|
|
20
|
+
const pad = 8;
|
|
21
|
+
const px = Math.max(0, Math.round((x - pad) * scale));
|
|
22
|
+
const py = Math.max(0, Math.round((y - pad) * scale));
|
|
23
|
+
source = join(OUTPUT_DIR, `${stem}.crop.png`);
|
|
24
|
+
await run("sips", [
|
|
25
|
+
"--cropOffset", String(py), String(px),
|
|
26
|
+
"-c", String(Math.round((h + 2 * pad) * scale)), String(Math.round((w + 2 * pad) * scale)),
|
|
27
|
+
full, "--out", source,
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
const small = join(OUTPUT_DIR, `${stem}.small.jpg`);
|
|
31
|
+
await run("sips", ["-s", "format", "jpeg", "-s", "formatOptions", "70", "--resampleWidth", String(opts.width), source, "--out", small]);
|
|
32
|
+
return { full, small, data: (await readFile(small)).toString("base64") };
|
|
33
|
+
}
|
|
34
|
+
/** Compares two PNG files; the actual image is scaled to the reference size first. */
|
|
35
|
+
export async function compare(actualPath, referencePath, threshold = 0.1) {
|
|
36
|
+
await mkdir(OUTPUT_DIR, { recursive: true });
|
|
37
|
+
const reference = PNG.sync.read(await readFile(referencePath));
|
|
38
|
+
let actual = PNG.sync.read(await readFile(actualPath));
|
|
39
|
+
if (actual.width !== reference.width || actual.height !== reference.height) {
|
|
40
|
+
const scaled = join(OUTPUT_DIR, `scaled-${stamp()}.png`);
|
|
41
|
+
await run("sips", ["-z", String(reference.height), String(reference.width), actualPath, "--out", scaled]);
|
|
42
|
+
actual = PNG.sync.read(await readFile(scaled));
|
|
43
|
+
}
|
|
44
|
+
const { width, height } = reference;
|
|
45
|
+
const diff = new PNG({ width, height });
|
|
46
|
+
const diffPixels = pixelmatch(reference.data, actual.data, diff.data, width, height, { threshold });
|
|
47
|
+
const diffImage = join(OUTPUT_DIR, `diff-${stamp()}.png`);
|
|
48
|
+
await writeFile(diffImage, PNG.sync.write(diff));
|
|
49
|
+
return { diffPixels, diffPercent: Math.round((diffPixels / (width * height)) * 10_000) / 100, diffImage };
|
|
50
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtemp, writeFile, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { background, run, simctl, sleep, CommandError } from "./exec.js";
|
|
9
|
+
import { listDevices, resolveDevice, ensureBooted } from "./devices.js";
|
|
10
|
+
import { newSession, sessionFor, call } from "./bridge.js";
|
|
11
|
+
import { screenshot, compare, OUTPUT_DIR } from "./images.js";
|
|
12
|
+
import { buildForSimulator, bundleIdOf } from "./build.js";
|
|
13
|
+
import { VERSION, newerVersion, updateHint } from "./version.js";
|
|
14
|
+
if (process.argv.length > 2) {
|
|
15
|
+
const { runCli } = await import("./cli.js");
|
|
16
|
+
process.exit(await runCli(process.argv.slice(2)));
|
|
17
|
+
}
|
|
18
|
+
const latest = await newerVersion();
|
|
19
|
+
const INSTRUCTIONS = `axbridge drives iOS Simulator apps as text.
|
|
20
|
+
Workflow: \`launch\` (or \`build_and_run\`) starts the app with the bridge and returns the screen as text.
|
|
21
|
+
Then use \`tap\`, \`type\`, \`scroll\`, \`back\`, \`go\`; each returns the new screen as text, so \`screen\` is rarely needed.
|
|
22
|
+
Targets for tap: exact or partial label/value text, "#3" (number from the last listing) or "id:identifier".
|
|
23
|
+
Use \`screenshot\` only to judge visuals; pass \`element\` to crop to one element. Several devices can run at once:
|
|
24
|
+
pass \`device\` (name, UDID or "booted") to every call.${latest ? `\n\n${updateHint(latest)}` : ""}`;
|
|
25
|
+
const server = new McpServer({ name: "axbridge", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
26
|
+
let updateMentioned = false;
|
|
27
|
+
function text(t) {
|
|
28
|
+
return { content: [{ type: "text", text: t }] };
|
|
29
|
+
}
|
|
30
|
+
function tool(name, description, shape, handler) {
|
|
31
|
+
server.registerTool(name, { description, inputSchema: shape }, (async (args) => {
|
|
32
|
+
try {
|
|
33
|
+
const result = await handler(args);
|
|
34
|
+
if (latest && !updateMentioned) {
|
|
35
|
+
updateMentioned = true;
|
|
36
|
+
result.content.push({ type: "text", text: `\n${updateHint(latest)}` });
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return { content: [{ type: "text", text: error.message }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
const device = z.string().optional().describe('Simulator name, UDID or "booted" (default). Required when several are booted.');
|
|
46
|
+
function describe(d) {
|
|
47
|
+
return `${d.name} (${d.runtime}) ${d.udid} ${d.state}`;
|
|
48
|
+
}
|
|
49
|
+
async function bootedDevice(query) {
|
|
50
|
+
const d = await resolveDevice(query);
|
|
51
|
+
await ensureBooted(d);
|
|
52
|
+
return d;
|
|
53
|
+
}
|
|
54
|
+
// MARK: Devices
|
|
55
|
+
tool("devices", "List simulators with name, runtime, UDID and state. Booted ones first.", {
|
|
56
|
+
all: z.boolean().optional().describe("Include unavailable devices"),
|
|
57
|
+
}, async ({ all }) => {
|
|
58
|
+
const list = (await listDevices())
|
|
59
|
+
.filter((d) => all || d.available)
|
|
60
|
+
.sort((a, b) => Number(b.state === "Booted") - Number(a.state === "Booted") || a.name.localeCompare(b.name));
|
|
61
|
+
return text(list.map(describe).join("\n") || "no simulators");
|
|
62
|
+
});
|
|
63
|
+
tool("boot", "Boot a simulator and wait until it is ready. Optionally open Simulator.app to watch.", {
|
|
64
|
+
device: z.string().describe("Name or UDID"),
|
|
65
|
+
show: z.boolean().optional().describe("Open Simulator.app"),
|
|
66
|
+
}, async ({ device: query, show }) => {
|
|
67
|
+
const d = await bootedDevice(query);
|
|
68
|
+
if (show)
|
|
69
|
+
background("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", d.udid]);
|
|
70
|
+
return text(`booted: ${describe({ ...d, state: "Booted" })}`);
|
|
71
|
+
});
|
|
72
|
+
tool("shutdown", 'Shut down a simulator, or every booted one with device "all".', { device }, async ({ device: query }) => {
|
|
73
|
+
if (query === "all") {
|
|
74
|
+
await simctl(["shutdown", "all"]);
|
|
75
|
+
return text("all simulators shut down");
|
|
76
|
+
}
|
|
77
|
+
const d = await resolveDevice(query);
|
|
78
|
+
await simctl(["shutdown", d.udid]);
|
|
79
|
+
return text(`shut down: ${d.name}`);
|
|
80
|
+
});
|
|
81
|
+
tool("create_device", 'Create a simulator, e.g. name "Test 13 mini", type "iPhone 13 mini". Runtime defaults to the newest iOS.', {
|
|
82
|
+
name: z.string(),
|
|
83
|
+
type: z.string().describe('Device type name as in `xcrun simctl list devicetypes`, e.g. "iPhone 17"'),
|
|
84
|
+
runtime: z.string().optional().describe('e.g. "iOS 27.2"; newest iOS when omitted'),
|
|
85
|
+
}, async ({ name, type, runtime }) => {
|
|
86
|
+
const raw = JSON.parse(await simctl(["list", "runtimes", "--json"]));
|
|
87
|
+
const ios = raw.runtimes.filter((r) => r.isAvailable && r.name.startsWith("iOS"));
|
|
88
|
+
const chosen = runtime ? ios.find((r) => r.name === runtime) : ios[ios.length - 1];
|
|
89
|
+
if (!chosen)
|
|
90
|
+
throw new CommandError(`No iOS runtime "${runtime}". Available: ${ios.map((r) => r.name).join(", ")}`);
|
|
91
|
+
const udid = await simctl(["create", name, type, chosen.identifier]);
|
|
92
|
+
return text(`created ${name} (${type}, ${chosen.name}) ${udid}`);
|
|
93
|
+
});
|
|
94
|
+
// MARK: App lifecycle
|
|
95
|
+
const launchShape = {
|
|
96
|
+
device,
|
|
97
|
+
reset: z.enum(["keep", "fresh"]).optional().describe("fresh deletes the app's data first, like a new install"),
|
|
98
|
+
args: z.array(z.string()).optional().describe("Extra launch arguments"),
|
|
99
|
+
env: z.record(z.string(), z.string()).optional().describe("Environment variables for the app"),
|
|
100
|
+
defaults: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
101
|
+
.describe("UserDefaults to write before launch, e.g. to mark onboarding as done"),
|
|
102
|
+
};
|
|
103
|
+
async function launchApp(d, bundleId, opts) {
|
|
104
|
+
// SwiftUI only builds its accessibility tree while accessibility is on.
|
|
105
|
+
for (const key of ["ApplicationAccessibilityEnabled", "AutomationEnabled"]) {
|
|
106
|
+
await simctl(["spawn", d.udid, "defaults", "write", "com.apple.Accessibility", key, "-bool", "true"]);
|
|
107
|
+
}
|
|
108
|
+
// The swipe-typing tutorial covers half the screen the first time a field is focused.
|
|
109
|
+
for (const domain of ["com.apple.Preferences", "com.apple.keyboard.preferences"]) {
|
|
110
|
+
await simctl(["spawn", d.udid, "defaults", "write", domain, "DidShowContinuousPathIntroduction", "-bool", "true"]);
|
|
111
|
+
}
|
|
112
|
+
await simctl(["terminate", d.udid, bundleId]).catch(() => undefined);
|
|
113
|
+
if (opts.reset === "fresh") {
|
|
114
|
+
const data = await simctl(["get_app_container", d.udid, bundleId, "data"]);
|
|
115
|
+
await run("/bin/rm", ["-rf", join(data, "Library"), join(data, "Documents"), join(data, "tmp")]);
|
|
116
|
+
}
|
|
117
|
+
for (const [key, value] of Object.entries(opts.defaults ?? {})) {
|
|
118
|
+
const kind = typeof value === "boolean" ? "-bool" : typeof value === "number" ? (Number.isInteger(value) ? "-int" : "-float") : "-string";
|
|
119
|
+
await simctl(["spawn", d.udid, "defaults", "write", bundleId, key, kind, String(value)]);
|
|
120
|
+
}
|
|
121
|
+
const session = await newSession(d.udid, bundleId);
|
|
122
|
+
const env = { ...process.env, SIMCTL_CHILD_AXBRIDGE_PORT: String(session.port), SIMCTL_CHILD_AXBRIDGE_TOKEN: session.token };
|
|
123
|
+
for (const [k, v] of Object.entries(opts.env ?? {}))
|
|
124
|
+
env[`SIMCTL_CHILD_${k}`] = v;
|
|
125
|
+
await simctl(["launch", "--terminate-running-process", d.udid, bundleId, "--axbridge", ...(opts.args ?? [])], { env });
|
|
126
|
+
const deadline = Date.now() + 30_000;
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
try {
|
|
129
|
+
const reply = await call(session, "GET", "/screen", "", 2_000);
|
|
130
|
+
if (reply.status === 200 && !reply.text.startsWith("(empty"))
|
|
131
|
+
return `${bundleId} running on ${d.name}\n\n${reply.text}`;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// still launching
|
|
135
|
+
}
|
|
136
|
+
await sleep(700);
|
|
137
|
+
}
|
|
138
|
+
throw new CommandError(`${bundleId} launched on ${d.name}, but the bridge did not answer within 30 s. Is AXBridge.start() called in a Debug build?`);
|
|
139
|
+
}
|
|
140
|
+
tool("launch", "Launch an installed app with the bridge and return its first screen as text.", {
|
|
141
|
+
bundleId: z.string(), ...launchShape,
|
|
142
|
+
}, async ({ device: query, bundleId, ...opts }) => text(await launchApp(await bootedDevice(query), bundleId, opts)));
|
|
143
|
+
tool("build_and_run", "Build an Xcode scheme for the simulator (Debug), install and launch it with the bridge.", {
|
|
144
|
+
project: z.string().optional().describe(".xcodeproj path"),
|
|
145
|
+
workspace: z.string().optional().describe(".xcworkspace path"),
|
|
146
|
+
scheme: z.string(),
|
|
147
|
+
configuration: z.string().optional().describe("Default Debug; the bridge exists only in Debug"),
|
|
148
|
+
...launchShape,
|
|
149
|
+
}, async ({ device: query, project, workspace, scheme, configuration, ...opts }) => {
|
|
150
|
+
const d = await bootedDevice(query);
|
|
151
|
+
const built = await buildForSimulator({ project, workspace, scheme, udid: d.udid, configuration: configuration ?? "Debug" });
|
|
152
|
+
await simctl(["install", d.udid, built.appPath]);
|
|
153
|
+
return text(await launchApp(d, built.bundleId, opts));
|
|
154
|
+
});
|
|
155
|
+
tool("install", "Install a .app bundle built for the simulator.", { device, appPath: z.string() }, async ({ device: query, appPath }) => {
|
|
156
|
+
const d = await bootedDevice(query);
|
|
157
|
+
await simctl(["install", d.udid, appPath]);
|
|
158
|
+
return text(`installed ${await bundleIdOf(appPath)} on ${d.name}`);
|
|
159
|
+
});
|
|
160
|
+
tool("terminate", "Quit an app.", { device, bundleId: z.string() }, async ({ device: query, bundleId }) => {
|
|
161
|
+
const d = await resolveDevice(query);
|
|
162
|
+
await simctl(["terminate", d.udid, bundleId]);
|
|
163
|
+
return text(`terminated ${bundleId}`);
|
|
164
|
+
});
|
|
165
|
+
tool("uninstall", "Remove an app and its data.", { device, bundleId: z.string() }, async ({ device: query, bundleId }) => {
|
|
166
|
+
const d = await resolveDevice(query);
|
|
167
|
+
await simctl(["uninstall", d.udid, bundleId]);
|
|
168
|
+
return text(`uninstalled ${bundleId}`);
|
|
169
|
+
});
|
|
170
|
+
// MARK: Reading and acting as text
|
|
171
|
+
async function bridgeCall(query, method, path, body = "") {
|
|
172
|
+
const d = await resolveDevice(query);
|
|
173
|
+
const reply = await call(await sessionFor(d.udid), method, path, body);
|
|
174
|
+
if (reply.status === 202)
|
|
175
|
+
return text(`${reply.text}\n(Use a coordinate tap from another tool for this element.)`);
|
|
176
|
+
if (reply.status >= 400)
|
|
177
|
+
throw new CommandError(reply.text);
|
|
178
|
+
return text(reply.text);
|
|
179
|
+
}
|
|
180
|
+
tool("screen", "The current screen as text: number, kind, label = value, id, centre point.", { device }, ({ device: q }) => bridgeCall(q, "GET", "/screen"));
|
|
181
|
+
tool("tap", 'Activate an element by text, "#n" from the last listing or "id:identifier". Returns the new screen.', {
|
|
182
|
+
target: z.string(), device,
|
|
183
|
+
}, ({ target, device: q }) => bridgeCall(q, "POST", "/tap", target));
|
|
184
|
+
tool("type", "Insert text into the focused field (tap it first). No keyboard needed; any characters work.", {
|
|
185
|
+
text: z.string(), clear: z.boolean().optional().describe("Empty the field first"), device,
|
|
186
|
+
}, async ({ text: value, clear, device: q }) => {
|
|
187
|
+
if (clear)
|
|
188
|
+
await bridgeCall(q, "POST", "/clear");
|
|
189
|
+
return bridgeCall(q, "POST", "/type", value);
|
|
190
|
+
});
|
|
191
|
+
tool("scroll", "Scroll the main scroll view of the screen.", {
|
|
192
|
+
direction: z.enum(["down", "up", "top", "bottom", "left", "right"]).optional(), device,
|
|
193
|
+
}, ({ direction, device: q }) => bridgeCall(q, "POST", "/scroll", direction ?? "down"));
|
|
194
|
+
tool("back", "Close the top sheet, or pop the navigation stack.", { device }, ({ device: q }) => bridgeCall(q, "POST", "/back"));
|
|
195
|
+
tool("go", "Open a deep link inside the app (no system prompt when the app passes openURL to AXBridge.start).", {
|
|
196
|
+
url: z.string(), device,
|
|
197
|
+
}, ({ url, device: q }) => bridgeCall(q, "POST", "/go", url));
|
|
198
|
+
// MARK: Device state
|
|
199
|
+
tool("set_location", "Set the simulated GPS position.", {
|
|
200
|
+
latitude: z.number(), longitude: z.number(), device,
|
|
201
|
+
}, async ({ latitude, longitude, device: q }) => {
|
|
202
|
+
const d = await bootedDevice(q);
|
|
203
|
+
await simctl(["location", d.udid, "set", `${latitude},${longitude}`]);
|
|
204
|
+
return text(`location ${latitude},${longitude} on ${d.name}`);
|
|
205
|
+
});
|
|
206
|
+
tool("set_appearance", "Switch light or dark mode.", { mode: z.enum(["light", "dark"]), device }, async ({ mode, device: q }) => {
|
|
207
|
+
const d = await bootedDevice(q);
|
|
208
|
+
await simctl(["ui", d.udid, "appearance", mode]);
|
|
209
|
+
return text(`${mode} mode on ${d.name}`);
|
|
210
|
+
});
|
|
211
|
+
const TEXT_SIZES = [
|
|
212
|
+
"extra-small", "small", "medium", "large", "extra-large", "extra-extra-large", "extra-extra-extra-large",
|
|
213
|
+
"accessibility-medium", "accessibility-large", "accessibility-extra-large", "accessibility-extra-extra-large",
|
|
214
|
+
"accessibility-extra-extra-extra-large",
|
|
215
|
+
];
|
|
216
|
+
tool("set_text_size", "Set Dynamic Type. large is the default; accessibility-* are the largest.", {
|
|
217
|
+
size: z.enum(TEXT_SIZES), device,
|
|
218
|
+
}, async ({ size, device: q }) => {
|
|
219
|
+
const d = await bootedDevice(q);
|
|
220
|
+
await simctl(["ui", d.udid, "content_size", size]);
|
|
221
|
+
return text(`text size ${size} on ${d.name}`);
|
|
222
|
+
});
|
|
223
|
+
tool("status_bar", "Clean status bar for screenshots (9:41, full battery and signal), or clear the override.", {
|
|
224
|
+
clear: z.boolean().optional(), time: z.string().optional().describe('Default "9:41"'), device,
|
|
225
|
+
}, async ({ clear, time, device: q }) => {
|
|
226
|
+
const d = await bootedDevice(q);
|
|
227
|
+
if (clear) {
|
|
228
|
+
await simctl(["status_bar", d.udid, "clear"]);
|
|
229
|
+
return text("status bar override cleared");
|
|
230
|
+
}
|
|
231
|
+
await simctl(["status_bar", d.udid, "override", "--time", time ?? "9:41", "--batteryState", "charged", "--batteryLevel", "100",
|
|
232
|
+
"--cellularMode", "active", "--cellularBars", "4", "--wifiBars", "3", "--dataNetwork", "wifi"]);
|
|
233
|
+
return text(`status bar ${time ?? "9:41"} on ${d.name}`);
|
|
234
|
+
});
|
|
235
|
+
tool("permission", "Grant, revoke or reset a privacy permission without the system prompt.", {
|
|
236
|
+
bundleId: z.string(),
|
|
237
|
+
action: z.enum(["grant", "revoke", "reset"]),
|
|
238
|
+
service: z.enum(["all", "calendar", "contacts-limited", "contacts", "location", "location-always", "photos-add", "photos",
|
|
239
|
+
"media-library", "microphone", "motion", "reminders", "siri", "camera", "notifications"]),
|
|
240
|
+
device,
|
|
241
|
+
}, async ({ bundleId, action, service, device: q }) => {
|
|
242
|
+
const d = await bootedDevice(q);
|
|
243
|
+
await simctl(["privacy", d.udid, action, service, bundleId]);
|
|
244
|
+
return text(`${action} ${service} for ${bundleId}`);
|
|
245
|
+
});
|
|
246
|
+
tool("push", "Deliver a push notification payload to the app.", {
|
|
247
|
+
bundleId: z.string(), payload: z.record(z.string(), z.unknown()).describe('APNs JSON, e.g. {"aps":{"alert":"Hello"}}'), device,
|
|
248
|
+
}, async ({ bundleId, payload, device: q }) => {
|
|
249
|
+
const d = await bootedDevice(q);
|
|
250
|
+
const dir = await mkdtemp(join(tmpdir(), "axbridge-push-"));
|
|
251
|
+
const file = join(dir, "payload.json");
|
|
252
|
+
await writeFile(file, JSON.stringify(payload));
|
|
253
|
+
await simctl(["push", d.udid, bundleId, file]);
|
|
254
|
+
return text(`push delivered to ${bundleId}`);
|
|
255
|
+
});
|
|
256
|
+
// MARK: Images
|
|
257
|
+
tool("screenshot", "Screenshot as a small JPEG for visual checks. element crops to one element; the full PNG path is returned too.", {
|
|
258
|
+
device,
|
|
259
|
+
element: z.string().optional().describe("Crop to this element (same targets as tap)"),
|
|
260
|
+
width: z.number().int().min(200).max(1600).optional().describe("Pixel width of the returned image, default 500"),
|
|
261
|
+
name: z.string().optional().describe("File name stem"),
|
|
262
|
+
}, async ({ device: q, element, width, name }) => {
|
|
263
|
+
const d = await bootedDevice(q);
|
|
264
|
+
let crop;
|
|
265
|
+
if (element) {
|
|
266
|
+
const reply = await call(await sessionFor(d.udid), "POST", "/frame", element);
|
|
267
|
+
if (reply.status !== 200)
|
|
268
|
+
throw new CommandError(reply.text);
|
|
269
|
+
const [x, y, w, h, scale] = reply.text.split(" ").map(Number);
|
|
270
|
+
crop = { x, y, w, h, scale };
|
|
271
|
+
}
|
|
272
|
+
const shot = await screenshot(d.udid, { name, width: width ?? 500, crop });
|
|
273
|
+
return { content: [{ type: "image", data: shot.data, mimeType: "image/jpeg" }, { type: "text", text: `full: ${shot.full}` }] };
|
|
274
|
+
});
|
|
275
|
+
tool("screenshots", "The same moment on several simulators, e.g. to compare layouts across widths.", {
|
|
276
|
+
devices: z.array(z.string()).min(1).max(6), width: z.number().int().min(200).max(1000).optional(),
|
|
277
|
+
}, async ({ devices: queries, width }) => {
|
|
278
|
+
const content = [];
|
|
279
|
+
for (const q of queries) {
|
|
280
|
+
const d = await bootedDevice(q);
|
|
281
|
+
const shot = await screenshot(d.udid, { name: `${d.name}-${Date.now()}`, width: width ?? 400 });
|
|
282
|
+
content.push({ type: "text", text: `${d.name}: ${shot.full}` }, { type: "image", data: shot.data, mimeType: "image/jpeg" });
|
|
283
|
+
}
|
|
284
|
+
return { content };
|
|
285
|
+
});
|
|
286
|
+
const recordings = new Map();
|
|
287
|
+
tool("record_start", "Start a screen recording (H.264 .mov).", { device }, async ({ device: q }) => {
|
|
288
|
+
const d = await bootedDevice(q);
|
|
289
|
+
if (recordings.has(d.udid))
|
|
290
|
+
throw new CommandError(`Already recording ${d.name}`);
|
|
291
|
+
const path = join(OUTPUT_DIR, `${d.name.replace(/\W+/g, "-")}-${Date.now()}.mov`);
|
|
292
|
+
const process = background("xcrun", ["simctl", "io", d.udid, "recordVideo", "--codec=h264", "--force", path]);
|
|
293
|
+
recordings.set(d.udid, { process, path });
|
|
294
|
+
return text(`recording ${d.name} → ${path}`);
|
|
295
|
+
});
|
|
296
|
+
tool("record_stop", "Stop the recording and return the file path.", { device }, async ({ device: q }) => {
|
|
297
|
+
const d = await resolveDevice(q);
|
|
298
|
+
const rec = recordings.get(d.udid);
|
|
299
|
+
if (!rec)
|
|
300
|
+
throw new CommandError(`No recording running on ${d.name}`);
|
|
301
|
+
rec.process.kill("SIGINT");
|
|
302
|
+
await new Promise((r) => rec.process.once("exit", r));
|
|
303
|
+
recordings.delete(d.udid);
|
|
304
|
+
return text(`saved ${rec.path}`);
|
|
305
|
+
});
|
|
306
|
+
tool("compare", "Pixel-compare the current screen (or an image file) with a reference PNG. Returns the share of differing pixels and a diff image.", {
|
|
307
|
+
reference: z.string().describe("Reference PNG path"),
|
|
308
|
+
actual: z.string().optional().describe("PNG path; takes a fresh screenshot of device when omitted"),
|
|
309
|
+
threshold: z.number().min(0).max(1).optional().describe("Per-pixel colour tolerance, default 0.1"),
|
|
310
|
+
device,
|
|
311
|
+
}, async ({ reference, actual, threshold, device: q }) => {
|
|
312
|
+
let actualPath = actual;
|
|
313
|
+
if (!actualPath) {
|
|
314
|
+
const d = await bootedDevice(q);
|
|
315
|
+
actualPath = (await screenshot(d.udid, { width: 400 })).full;
|
|
316
|
+
}
|
|
317
|
+
const result = await compare(actualPath, reference, threshold ?? 0.1);
|
|
318
|
+
return text(`${result.diffPercent}% of pixels differ (${result.diffPixels}). Diff image: ${result.diffImage}`);
|
|
319
|
+
});
|
|
320
|
+
// MARK: Updates
|
|
321
|
+
async function findPins(root, depth = 0, found = []) {
|
|
322
|
+
if (depth > 5 || found.length > 20)
|
|
323
|
+
return found;
|
|
324
|
+
let entries;
|
|
325
|
+
try {
|
|
326
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return found;
|
|
330
|
+
}
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
if (entry.name.startsWith(".") && entry.name !== ".mcp.json" && entry.name !== ".cursor" && entry.name !== ".codex")
|
|
333
|
+
continue;
|
|
334
|
+
if (["node_modules", "DerivedData", "build", "Pods", ".build"].includes(entry.name))
|
|
335
|
+
continue;
|
|
336
|
+
const path = join(root, entry.name);
|
|
337
|
+
if (entry.isDirectory()) {
|
|
338
|
+
await findPins(path, depth + 1, found);
|
|
339
|
+
}
|
|
340
|
+
else if (/^(Package\.swift|Package\.resolved|project\.yml|project\.pbxproj|\.mcp\.json|mcp\.json|config\.toml)$/.test(entry.name)) {
|
|
341
|
+
const lines = (await readFile(path, "utf8").catch(() => "")).split("\n");
|
|
342
|
+
lines.forEach((line, i) => {
|
|
343
|
+
if (/axbridge/i.test(line) && /\d+\.\d+\.\d+|revision|version/i.test(line))
|
|
344
|
+
found.push(`${path}:${i + 1}: ${line.trim()}`);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return found;
|
|
349
|
+
}
|
|
350
|
+
tool("update", "Check for a newer axbridge, preload it for npx and list where the app and agent configs pin a version.", {
|
|
351
|
+
project: z.string().optional().describe("App repository to search for version pins (Package.swift, Package.resolved, project.yml, MCP configs)"),
|
|
352
|
+
}, async ({ project }) => {
|
|
353
|
+
const newest = await newerVersion(5000);
|
|
354
|
+
const lines = [];
|
|
355
|
+
if (!newest) {
|
|
356
|
+
lines.push(`axbridge-mcp ${VERSION} is the newest version (or npm is unreachable).`);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
await run("npx", ["-y", `axbridge-mcp@${newest}`, "--version"], { timeoutMs: 180_000 });
|
|
360
|
+
lines.push(`axbridge ${newest} is available, this server runs ${VERSION}. npx now has ${newest} cached.`, "1. Restart the axbridge MCP server so the new version runs (Claude Code: /mcp, then reconnect axbridge; Codex and Cursor: restart the session). If a config pins a version, raise it.", `2. Raise the AXBridge Swift package to ${newest} (tag v${newest}) and rebuild the app; app and server should run the same version.`, `Release notes: https://github.com/jx-grxf/axbridge/releases/tag/v${newest}`);
|
|
361
|
+
}
|
|
362
|
+
if (project) {
|
|
363
|
+
const pins = await findPins(project);
|
|
364
|
+
lines.push("", pins.length ? `Version pins in ${project}:\n${pins.join("\n")}` : `No axbridge version pins found in ${project}.`);
|
|
365
|
+
}
|
|
366
|
+
return text(lines.join("\n"));
|
|
367
|
+
});
|
|
368
|
+
const transport = new StdioServerTransport();
|
|
369
|
+
await server.connect(transport);
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const VERSION = "0.1.1";
|
|
5
|
+
const CACHE = join(homedir(), ".cache", "axbridge", "update.json");
|
|
6
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Semver-ish comparison of x.y.z; pre-release tags count as older than the release. */
|
|
8
|
+
export function isNewer(candidate, current) {
|
|
9
|
+
const parse = (v) => {
|
|
10
|
+
const [core, pre] = v.replace(/^v/, "").split("-", 2);
|
|
11
|
+
return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre: pre ?? "" };
|
|
12
|
+
};
|
|
13
|
+
const a = parse(candidate), b = parse(current);
|
|
14
|
+
for (let i = 0; i < 3; i++) {
|
|
15
|
+
const diff = (a.nums[i] ?? 0) - (b.nums[i] ?? 0);
|
|
16
|
+
if (diff !== 0)
|
|
17
|
+
return diff > 0;
|
|
18
|
+
}
|
|
19
|
+
if (a.pre === b.pre)
|
|
20
|
+
return false;
|
|
21
|
+
if (!a.pre)
|
|
22
|
+
return true;
|
|
23
|
+
if (!b.pre)
|
|
24
|
+
return false;
|
|
25
|
+
return a.pre > b.pre;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The newest published version, or null when offline, disabled or not newer. Checks npm at
|
|
29
|
+
* most once a day and never waits longer than `timeoutMs`.
|
|
30
|
+
*/
|
|
31
|
+
export async function newerVersion(timeoutMs = 1500) {
|
|
32
|
+
if (process.env.AXBRIDGE_NO_UPDATE_CHECK)
|
|
33
|
+
return null;
|
|
34
|
+
let latest;
|
|
35
|
+
try {
|
|
36
|
+
const cached = JSON.parse(await readFile(CACHE, "utf8"));
|
|
37
|
+
if (Date.now() - cached.checkedAt < DAY)
|
|
38
|
+
latest = cached.latest;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// no cache yet
|
|
42
|
+
}
|
|
43
|
+
if (!latest) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch("https://registry.npmjs.org/axbridge-mcp/latest", { signal: AbortSignal.timeout(timeoutMs) });
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
return null;
|
|
48
|
+
latest = (await response.json()).version;
|
|
49
|
+
await mkdir(join(homedir(), ".cache", "axbridge"), { recursive: true });
|
|
50
|
+
await writeFile(CACHE, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return latest && isNewer(latest, VERSION) ? latest : null;
|
|
57
|
+
}
|
|
58
|
+
export function updateHint(latest) {
|
|
59
|
+
return `axbridge ${latest} is available (running ${VERSION}). Call the \`update\` tool, or tell the user.`;
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "axbridge-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP server that lets coding agents drive iOS Simulator apps as text: read the screen from the accessibility tree, tap by name, type, scroll, plus device, app and screenshot tools.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"ios",
|
|
9
|
+
"ios-simulator",
|
|
10
|
+
"simulator",
|
|
11
|
+
"xcode",
|
|
12
|
+
"swiftui",
|
|
13
|
+
"accessibility",
|
|
14
|
+
"ui-testing",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"codex",
|
|
17
|
+
"cursor",
|
|
18
|
+
"ai-agent"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Johannes Grof",
|
|
22
|
+
"homepage": "https://github.com/jx-grxf/axbridge#readme",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/jx-grxf/axbridge.git",
|
|
26
|
+
"directory": "mcp"
|
|
27
|
+
},
|
|
28
|
+
"bugs": "https://github.com/jx-grxf/axbridge/issues",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bin": {
|
|
31
|
+
"axbridge-mcp": "dist/index.js",
|
|
32
|
+
"axbridge": "dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"os": [
|
|
43
|
+
"darwin"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"prepublishOnly": "npm run build && npm test"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
52
|
+
"pixelmatch": "^8.0.0",
|
|
53
|
+
"pngjs": "^7.0.0",
|
|
54
|
+
"zod": "^4.6.7"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"@types/pngjs": "^6.0.5",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vitest": "^5.1.0"
|
|
61
|
+
}
|
|
62
|
+
}
|