nerve-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +490 -341
- package/framework/Nerve.framework/Info.plist +0 -0
- package/framework/Nerve.framework/Nerve +0 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -42,253 +42,95 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
|
42
42
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
43
43
|
const ws_1 = __importDefault(require("ws"));
|
|
44
44
|
const child_process_1 = require("child_process");
|
|
45
|
-
const fs = __importStar(require("fs"));
|
|
46
45
|
const path = __importStar(require("path"));
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const info = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
58
|
-
// Check if process is still alive
|
|
59
|
-
try {
|
|
60
|
-
process.kill(info.pid, 0);
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Process is dead, clean up stale file
|
|
64
|
-
fs.unlinkSync(path.join(dir, file));
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
targets.push({
|
|
68
|
-
id: `sim:${info.udid}:${info.bundleId}`,
|
|
69
|
-
platform: "simulator",
|
|
70
|
-
bundleId: info.bundleId,
|
|
71
|
-
appName: info.appName,
|
|
72
|
-
port: info.port,
|
|
73
|
-
host: "127.0.0.1",
|
|
74
|
-
udid: info.udid,
|
|
75
|
-
connected: false,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
// Skip malformed files
|
|
80
|
-
}
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const net = __importStar(require("net"));
|
|
48
|
+
// --- Port Resolution ---
|
|
49
|
+
// djb2 hash — must match the Swift side exactly
|
|
50
|
+
function nervePort(udid, bundleId) {
|
|
51
|
+
const key = `${udid}-${bundleId}`;
|
|
52
|
+
let hash = 5381;
|
|
53
|
+
for (let i = 0; i < key.length; i++) {
|
|
54
|
+
hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0; // djb2, unsigned 32-bit
|
|
81
55
|
}
|
|
82
|
-
return
|
|
56
|
+
return 10000 + (hash % 55000);
|
|
83
57
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// Resolve would need another dns-sd call. For MVP, skip and rely on
|
|
94
|
-
// manual connection or iproxy.
|
|
95
|
-
}
|
|
58
|
+
// Track the active target (set by nerve_run, or auto-detected from booted sims)
|
|
59
|
+
let activeTarget = null;
|
|
60
|
+
let requestCounter = 0;
|
|
61
|
+
function resolvePort(targetId) {
|
|
62
|
+
if (targetId) {
|
|
63
|
+
// targetId format: "sim:{udid}:{bundleId}"
|
|
64
|
+
const parts = targetId.split(":");
|
|
65
|
+
if (parts.length >= 3 && parts[0] === "sim") {
|
|
66
|
+
return nervePort(parts[1], parts.slice(2).join(":"));
|
|
96
67
|
}
|
|
97
|
-
|
|
68
|
+
throw new Error(`Unknown target format: ${targetId}`);
|
|
98
69
|
}
|
|
99
|
-
|
|
100
|
-
|
|
70
|
+
if (!activeTarget) {
|
|
71
|
+
throw new Error("No active target. Use nerve_run to launch an app first, or specify a target.");
|
|
101
72
|
}
|
|
73
|
+
return nervePort(activeTarget.udid, activeTarget.bundleId);
|
|
102
74
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
async discover() {
|
|
109
|
-
const simTargets = discoverSimulatorTargets();
|
|
110
|
-
const bonjourTargets = discoverBonjourTargets();
|
|
111
|
-
const all = [...simTargets, ...bonjourTargets];
|
|
112
|
-
for (const target of all) {
|
|
113
|
-
const existing = this.targets.get(target.id);
|
|
114
|
-
if (!existing) {
|
|
115
|
-
// New target
|
|
116
|
-
this.targets.set(target.id, target);
|
|
117
|
-
this.connect(target);
|
|
118
|
-
}
|
|
119
|
-
else if (!existing.connected && target.port !== existing.port) {
|
|
120
|
-
// App restarted on a new port — reconnect
|
|
121
|
-
console.error(`[nerve] Re-discovered ${target.appName} on port ${target.port} (was ${existing.port})`);
|
|
122
|
-
existing.port = target.port;
|
|
123
|
-
// Cancel pending reconnect timer
|
|
124
|
-
const timer = this.reconnectTimers.get(target.id);
|
|
125
|
-
if (timer) {
|
|
126
|
-
clearTimeout(timer);
|
|
127
|
-
this.reconnectTimers.delete(target.id);
|
|
128
|
-
}
|
|
129
|
-
this.connect(existing);
|
|
130
|
-
}
|
|
131
|
-
else if (!existing.connected && !existing.ws) {
|
|
132
|
-
// Same port but disconnected — try reconnecting
|
|
133
|
-
const timer = this.reconnectTimers.get(target.id);
|
|
134
|
-
if (!timer) {
|
|
135
|
-
this.connect(existing);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return all;
|
|
140
|
-
}
|
|
141
|
-
connect(target) {
|
|
142
|
-
const url = `ws://${target.host}:${target.port}`;
|
|
75
|
+
async function send(command, params = {}, targetId, timeoutMs = 30000) {
|
|
76
|
+
const port = resolvePort(targetId);
|
|
77
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
78
|
+
const id = `req_${++requestCounter}`;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
143
80
|
const ws = new ws_1.default(url);
|
|
144
|
-
let
|
|
81
|
+
let settled = false;
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
if (!settled) {
|
|
84
|
+
settled = true;
|
|
85
|
+
ws.terminate();
|
|
86
|
+
reject(new Error(`Command '${command}' timed out after ${timeoutMs / 1000}s`));
|
|
87
|
+
}
|
|
88
|
+
}, timeoutMs);
|
|
145
89
|
ws.on("open", () => {
|
|
146
|
-
|
|
147
|
-
target.connected = true;
|
|
148
|
-
console.error(`[nerve] Connected to ${target.appName} (${target.platform})`);
|
|
149
|
-
// Health check: ping every 30s, force-close if no pong within 10s
|
|
150
|
-
pingInterval = setInterval(() => {
|
|
151
|
-
if (ws.readyState !== ws_1.default.OPEN)
|
|
152
|
-
return;
|
|
153
|
-
let pongReceived = false;
|
|
154
|
-
ws.once("pong", () => { pongReceived = true; });
|
|
155
|
-
ws.ping();
|
|
156
|
-
setTimeout(() => {
|
|
157
|
-
if (!pongReceived && ws.readyState === ws_1.default.OPEN) {
|
|
158
|
-
console.error(`[nerve] No pong from ${target.appName} — forcing reconnect`);
|
|
159
|
-
ws.terminate();
|
|
160
|
-
}
|
|
161
|
-
}, 10000);
|
|
162
|
-
}, 30000);
|
|
90
|
+
ws.send(JSON.stringify({ id, command, params }));
|
|
163
91
|
});
|
|
164
92
|
ws.on("message", (data) => {
|
|
165
93
|
try {
|
|
166
94
|
const response = JSON.parse(data.toString());
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
clearTimeout(
|
|
170
|
-
|
|
171
|
-
if (response.ok)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
pending.reject(new Error(response.data));
|
|
176
|
-
}
|
|
95
|
+
if (response.id === id && !settled) {
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
ws.close();
|
|
99
|
+
if (response.ok)
|
|
100
|
+
resolve(response.data);
|
|
101
|
+
else
|
|
102
|
+
reject(new Error(response.data));
|
|
177
103
|
}
|
|
178
104
|
}
|
|
179
|
-
catch {
|
|
180
|
-
|
|
105
|
+
catch { /* ignore malformed */ }
|
|
106
|
+
});
|
|
107
|
+
ws.on("error", (err) => {
|
|
108
|
+
if (!settled) {
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
reject(new Error(`Connection error: ${err.message}`));
|
|
181
112
|
}
|
|
182
113
|
});
|
|
183
114
|
ws.on("close", () => {
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
console.error(`[nerve] Disconnected from ${target.appName}`);
|
|
189
|
-
this.scheduleReconnect(target);
|
|
190
|
-
});
|
|
191
|
-
ws.on("error", () => {
|
|
192
|
-
if (pingInterval)
|
|
193
|
-
clearInterval(pingInterval);
|
|
194
|
-
target.connected = false;
|
|
195
|
-
target.ws = undefined;
|
|
196
|
-
this.scheduleReconnect(target);
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
reconnectTimers = new Map();
|
|
200
|
-
scheduleReconnect(target, attempt = 0) {
|
|
201
|
-
// Don't schedule if already pending
|
|
202
|
-
if (this.reconnectTimers.has(target.id))
|
|
203
|
-
return;
|
|
204
|
-
// Give up after 60 attempts (~2 minutes)
|
|
205
|
-
if (attempt > 60) {
|
|
206
|
-
console.error(`[nerve] Gave up reconnecting to ${target.appName}`);
|
|
207
|
-
this.targets.delete(target.id);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const delay = Math.min(2000, 500 + attempt * 200);
|
|
211
|
-
const timer = setTimeout(() => {
|
|
212
|
-
// Re-read port file — app may have restarted on a new port
|
|
213
|
-
if (target.platform === "simulator" && target.udid) {
|
|
214
|
-
const portFile = path.join("/tmp/nerve-ports", `${target.udid}-${target.bundleId}.json`);
|
|
215
|
-
try {
|
|
216
|
-
const info = JSON.parse(fs.readFileSync(portFile, "utf-8"));
|
|
217
|
-
try {
|
|
218
|
-
process.kill(info.pid, 0);
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
// Process dead and port file stale — wait for new one
|
|
222
|
-
this.reconnectTimers.delete(target.id);
|
|
223
|
-
this.scheduleReconnect(target, attempt + 1);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (info.port !== target.port) {
|
|
227
|
-
console.error(`[nerve] ${target.appName} restarted on port ${info.port} (was ${target.port})`);
|
|
228
|
-
target.port = info.port;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
// Port file gone — wait for app to write a new one
|
|
233
|
-
this.reconnectTimers.delete(target.id);
|
|
234
|
-
this.scheduleReconnect(target, attempt + 1);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
115
|
+
if (!settled) {
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
reject(new Error("Connection closed before response"));
|
|
237
119
|
}
|
|
238
|
-
this.reconnectTimers.delete(target.id);
|
|
239
|
-
this.connect(target);
|
|
240
|
-
}, delay);
|
|
241
|
-
this.reconnectTimers.set(target.id, timer);
|
|
242
|
-
}
|
|
243
|
-
getTarget(targetId) {
|
|
244
|
-
if (targetId) {
|
|
245
|
-
return this.targets.get(targetId);
|
|
246
|
-
}
|
|
247
|
-
// Auto-select if only one connected target
|
|
248
|
-
const connected = Array.from(this.targets.values()).filter(t => t.connected);
|
|
249
|
-
if (connected.length === 1)
|
|
250
|
-
return connected[0];
|
|
251
|
-
return undefined;
|
|
252
|
-
}
|
|
253
|
-
getConnectedTargets() {
|
|
254
|
-
return Array.from(this.targets.values()).filter(t => t.connected);
|
|
255
|
-
}
|
|
256
|
-
async send(command, params = {}, targetId) {
|
|
257
|
-
const target = this.getTarget(targetId);
|
|
258
|
-
if (!target?.ws || !target.connected) {
|
|
259
|
-
// Try discovery first
|
|
260
|
-
await this.discover();
|
|
261
|
-
const retryTarget = this.getTarget(targetId);
|
|
262
|
-
if (!retryTarget?.ws || !retryTarget.connected) {
|
|
263
|
-
const connected = this.getConnectedTargets();
|
|
264
|
-
if (connected.length === 0) {
|
|
265
|
-
throw new Error("No Nerve instance found. Make sure your iOS app is running with Nerve.start() or launched via `nerve launch`.");
|
|
266
|
-
}
|
|
267
|
-
if (connected.length > 1 && !targetId) {
|
|
268
|
-
const list = connected.map(t => ` ${t.id} — ${t.appName} (${t.platform})`).join("\n");
|
|
269
|
-
throw new Error(`Multiple targets connected. Specify 'target' parameter:\n${list}`);
|
|
270
|
-
}
|
|
271
|
-
throw new Error("Target not found or not connected.");
|
|
272
|
-
}
|
|
273
|
-
return this.sendToTarget(retryTarget, command, params);
|
|
274
|
-
}
|
|
275
|
-
return this.sendToTarget(target, command, params);
|
|
276
|
-
}
|
|
277
|
-
sendToTarget(target, command, params) {
|
|
278
|
-
return new Promise((resolve, reject) => {
|
|
279
|
-
const id = `req_${++this.requestCounter}`;
|
|
280
|
-
const timer = setTimeout(() => {
|
|
281
|
-
this.pendingRequests.delete(id);
|
|
282
|
-
reject(new Error(`Command '${command}' timed out after 10s`));
|
|
283
|
-
}, 10000);
|
|
284
|
-
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
285
|
-
const msg = JSON.stringify({ id, command, params });
|
|
286
|
-
target.ws.send(msg);
|
|
287
120
|
});
|
|
288
|
-
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function tcpProbe(host, port, timeoutMs) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const socket = new net.Socket();
|
|
126
|
+
socket.setTimeout(timeoutMs);
|
|
127
|
+
socket.on("connect", () => { socket.destroy(); resolve(true); });
|
|
128
|
+
socket.on("error", () => { socket.destroy(); resolve(false); });
|
|
129
|
+
socket.on("timeout", () => { socket.destroy(); resolve(false); });
|
|
130
|
+
socket.connect(port, host);
|
|
131
|
+
});
|
|
289
132
|
}
|
|
290
133
|
// --- MCP Server ---
|
|
291
|
-
const connection = new NerveConnection();
|
|
292
134
|
const server = new index_js_1.Server({ name: "nerve", version: "0.1.0" }, {
|
|
293
135
|
capabilities: { tools: {} },
|
|
294
136
|
instructions: `Nerve is an in-process iOS automation framework. It lets you see, interact with, and inspect a running iOS app from within the app's process.
|
|
@@ -320,7 +162,7 @@ The tap= coordinate is the center point where the element is reliably hittable.
|
|
|
320
162
|
- nerve_back to go back, nerve_dismiss to close modals/keyboard.
|
|
321
163
|
- nerve_map to see all discovered screens. nerve_navigate to auto-navigate to a known screen.
|
|
322
164
|
- nerve_deeplink to open a URL scheme directly.
|
|
323
|
-
-
|
|
165
|
+
- Interaction commands (tap, scroll, type, swipe, back, dismiss) automatically return the updated screen state — no need to call nerve_view after them.
|
|
324
166
|
|
|
325
167
|
### Interact
|
|
326
168
|
- nerve_tap to press buttons and select items. Use @eN refs or #id.
|
|
@@ -334,14 +176,15 @@ The tap= coordinate is the center point where the element is reliably hittable.
|
|
|
334
176
|
- Note: auto-wait after actions only covers UI settling (animations, transitions). For network completion, use nerve_wait_idle or nerve_network explicitly.
|
|
335
177
|
|
|
336
178
|
### Verify
|
|
337
|
-
- nerve_view to see updated screen state.
|
|
179
|
+
- nerve_view to see updated screen state — this is your PRIMARY inspection tool. It returns structured element data with refs, identifiers, and tap coordinates you can act on directly.
|
|
338
180
|
- nerve_console with filter="[nerve]" and since="last_action" for your trace logs.
|
|
339
|
-
- nerve_screenshot
|
|
181
|
+
- Do NOT use nerve_screenshot unless nerve_view is insufficient (e.g., verifying colors, gradients, or visual layout). When you must screenshot, ALWAYS crop to the relevant element: nerve_screenshot with element="#my-element" instead of capturing the full screen.
|
|
340
182
|
- nerve_heap to inspect live objects (e.g., check ViewModel state).
|
|
341
183
|
|
|
342
184
|
### Tips
|
|
343
185
|
- Always call nerve_view before interacting — don't guess element identifiers.
|
|
344
186
|
- Use @eN refs from nerve_view output to tap elements without identifiers.
|
|
187
|
+
- nerve_view is lightweight (~1 line per element) and gives you everything needed to interact. NEVER use nerve_screenshot when nerve_view can answer the question.
|
|
345
188
|
- If an element isn't visible, try nerve_scroll_to_find before giving up.
|
|
346
189
|
- The navigation map builds automatically and persists across sessions.
|
|
347
190
|
- Do NOT add sleep/delay between commands — Nerve handles waiting automatically.
|
|
@@ -384,7 +227,7 @@ const TOOLS = [
|
|
|
384
227
|
},
|
|
385
228
|
{
|
|
386
229
|
name: "nerve_tap",
|
|
387
|
-
description: "Tap a UI element
|
|
230
|
+
description: "Tap a UI element. Response includes the updated screen state (auto-view), so you do NOT need to call nerve_view after tapping. Use #identifier, @label, or x,y coordinates.",
|
|
388
231
|
inputSchema: {
|
|
389
232
|
type: "object",
|
|
390
233
|
properties: {
|
|
@@ -529,12 +372,16 @@ const TOOLS = [
|
|
|
529
372
|
},
|
|
530
373
|
{
|
|
531
374
|
name: "nerve_screenshot",
|
|
532
|
-
description: "Capture a screenshot
|
|
375
|
+
description: "Capture a screenshot. WARNING: Screenshots consume significant tokens — use nerve_view for all normal inspection. Only use for visual checks (colors, gradients, images, layout). Use 'element' to crop to a specific element (cheapest), or 'region' to crop to a normalized area. Avoid full-screen screenshots when possible.",
|
|
533
376
|
inputSchema: {
|
|
534
377
|
type: "object",
|
|
535
378
|
properties: {
|
|
536
379
|
target: { type: "string" },
|
|
380
|
+
element: { type: "string", description: "Crop to a specific element. Use @eN ref, #identifier, or @label from nerve_view. This is the most token-efficient way to verify visual appearance." },
|
|
381
|
+
region: { type: "string", description: "Crop to a normalized region: \"x1,y1,x2,y2\" where values are 0-1. Example: \"0,0,0.5,0.5\" for top-left quarter." },
|
|
382
|
+
padding: { type: "number", description: "Padding in points around the element crop. Default: 20." },
|
|
537
383
|
scale: { type: "number", description: "Image scale. Default: 1.0." },
|
|
384
|
+
maxDimension: { type: "number", description: "Resize so longest side fits within this value (in points). Example: 800. Overrides scale when set." },
|
|
538
385
|
},
|
|
539
386
|
},
|
|
540
387
|
},
|
|
@@ -687,7 +534,7 @@ const TOOLS = [
|
|
|
687
534
|
},
|
|
688
535
|
{
|
|
689
536
|
name: "nerve_run",
|
|
690
|
-
description: "Build, install, and launch an iOS app on the simulator.
|
|
537
|
+
description: "Build, install, and launch an iOS app on the simulator. Nerve is auto-injected if the app doesn't include it via SPM — no code changes needed. After launching, call nerve_view to see the initial screen.",
|
|
691
538
|
inputSchema: {
|
|
692
539
|
type: "object",
|
|
693
540
|
properties: {
|
|
@@ -749,6 +596,41 @@ const TOOLS = [
|
|
|
749
596
|
required: ["simulator"],
|
|
750
597
|
},
|
|
751
598
|
},
|
|
599
|
+
{
|
|
600
|
+
name: "nerve_appearance",
|
|
601
|
+
description: "Switch between light and dark mode. On simulator, uses simctl. On device or if Nerve is connected, overrides the app's window trait collection.",
|
|
602
|
+
inputSchema: {
|
|
603
|
+
type: "object",
|
|
604
|
+
properties: {
|
|
605
|
+
mode: { type: "string", enum: ["dark", "light", "toggle"], description: "Appearance mode. 'toggle' switches from current." },
|
|
606
|
+
target: { type: "string" },
|
|
607
|
+
},
|
|
608
|
+
required: ["mode"],
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "nerve_run_device",
|
|
613
|
+
description: "Build, install, and launch an iOS app on a connected physical device. Requires the device to be paired and a valid code signing identity.",
|
|
614
|
+
inputSchema: {
|
|
615
|
+
type: "object",
|
|
616
|
+
properties: {
|
|
617
|
+
scheme: { type: "string", description: "Xcode scheme to build." },
|
|
618
|
+
workspace: { type: "string", description: "Path to .xcworkspace (optional)." },
|
|
619
|
+
project: { type: "string", description: "Path to .xcodeproj (optional)." },
|
|
620
|
+
device: { type: "string", description: "Device name or UDID. If omitted, uses the first available paired device." },
|
|
621
|
+
team: { type: "string", description: "Apple Development Team ID for code signing (optional if set in Xcode project)." },
|
|
622
|
+
},
|
|
623
|
+
required: ["scheme"],
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: "nerve_list_devices",
|
|
628
|
+
description: "List connected physical iOS devices and their state (available/unavailable).",
|
|
629
|
+
inputSchema: {
|
|
630
|
+
type: "object",
|
|
631
|
+
properties: {},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
752
634
|
{
|
|
753
635
|
name: "nerve_trace",
|
|
754
636
|
description: "Trace method calls at runtime via swizzling. Logs every invocation to the console (read with nerve_console). Zero overhead compared to LLDB breakpoints. Use this to understand code flow without rebuilding.",
|
|
@@ -809,6 +691,36 @@ const TOOLS = [
|
|
|
809
691
|
required: [],
|
|
810
692
|
},
|
|
811
693
|
},
|
|
694
|
+
{
|
|
695
|
+
name: "nerve_sequence",
|
|
696
|
+
description: "Execute multiple Nerve commands in a single call. Much faster than calling tools one-by-one because it eliminates round-trip delays between steps. Each step waits for UI to settle before the next runs. Returns the result of each step. Use this when you already know the sequence of actions to perform (e.g., tap tab → tap row → type text → submit).",
|
|
697
|
+
inputSchema: {
|
|
698
|
+
type: "object",
|
|
699
|
+
properties: {
|
|
700
|
+
target: { type: "string", description: "Target ID. Auto-selects if only one connected." },
|
|
701
|
+
steps: {
|
|
702
|
+
type: "array",
|
|
703
|
+
description: "Array of commands to execute in order.",
|
|
704
|
+
items: {
|
|
705
|
+
type: "object",
|
|
706
|
+
properties: {
|
|
707
|
+
command: {
|
|
708
|
+
type: "string",
|
|
709
|
+
description: "Nerve command: tap, type, scroll, swipe, back, dismiss, view, double_tap, long_press, context_menu, drag_drop, pull_to_refresh, pinch, scroll_to_find, wait_idle, inspect, screenshot",
|
|
710
|
+
},
|
|
711
|
+
params: {
|
|
712
|
+
type: "object",
|
|
713
|
+
description: "Parameters for the command (e.g., {\"query\": \"#login-btn\"} for tap, {\"text\": \"hello\"} for type).",
|
|
714
|
+
additionalProperties: true,
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
required: ["command"],
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
required: ["steps"],
|
|
722
|
+
},
|
|
723
|
+
},
|
|
812
724
|
];
|
|
813
725
|
// --- LLDB Session (Mac-side) ---
|
|
814
726
|
class LLDBSession {
|
|
@@ -930,35 +842,24 @@ async function handleLLDB(params) {
|
|
|
930
842
|
}
|
|
931
843
|
// Auto-attach if not already connected
|
|
932
844
|
if (!lldbSession.isAttached()) {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
if (fs.existsSync(portFile)) {
|
|
939
|
-
const info = JSON.parse(fs.readFileSync(portFile, "utf-8"));
|
|
940
|
-
pid = info.pid;
|
|
941
|
-
}
|
|
845
|
+
if (!activeTarget) {
|
|
846
|
+
return {
|
|
847
|
+
content: [{ type: "text", text: "Error: No running app found. Launch the app first with nerve_run." }],
|
|
848
|
+
isError: true,
|
|
849
|
+
};
|
|
942
850
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const info = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
951
|
-
process.kill(info.pid, 0); // Check alive
|
|
952
|
-
pid = info.pid;
|
|
953
|
-
break;
|
|
954
|
-
}
|
|
955
|
-
catch { }
|
|
956
|
-
}
|
|
957
|
-
}
|
|
851
|
+
// Find PID by bundle ID from simctl
|
|
852
|
+
let pid;
|
|
853
|
+
try {
|
|
854
|
+
const out = await runShell(`xcrun simctl spawn "${activeTarget.udid}" launchctl list 2>/dev/null | grep "${activeTarget.bundleId}" | awk '{print $1}'`);
|
|
855
|
+
const parsed = parseInt(out.trim());
|
|
856
|
+
if (!isNaN(parsed))
|
|
857
|
+
pid = parsed;
|
|
958
858
|
}
|
|
859
|
+
catch { }
|
|
959
860
|
if (!pid) {
|
|
960
861
|
return {
|
|
961
|
-
content: [{ type: "text", text: "Error:
|
|
862
|
+
content: [{ type: "text", text: "Error: Could not find running app process. Launch the app first with nerve_run." }],
|
|
962
863
|
isError: true,
|
|
963
864
|
};
|
|
964
865
|
}
|
|
@@ -1064,6 +965,15 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1064
965
|
if (command === "build" || command === "run") {
|
|
1065
966
|
return handleBuildRun(command, params);
|
|
1066
967
|
}
|
|
968
|
+
if (command === "run_device") {
|
|
969
|
+
return handleRunDevice(params);
|
|
970
|
+
}
|
|
971
|
+
if (command === "appearance") {
|
|
972
|
+
return handleAppearance(params, targetId);
|
|
973
|
+
}
|
|
974
|
+
if (command === "list_devices") {
|
|
975
|
+
return handleListDevices();
|
|
976
|
+
}
|
|
1067
977
|
// Grant permissions via simctl (Mac-side)
|
|
1068
978
|
if (command === "grant_permissions") {
|
|
1069
979
|
return handleGrantPermissions(params);
|
|
@@ -1080,37 +990,56 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1080
990
|
return handleBootSimulator(params);
|
|
1081
991
|
}
|
|
1082
992
|
// Deeplink via simctl (Mac-side, when method is "simctl")
|
|
1083
|
-
if (command === "deeplink" && (params.method === "simctl" || !
|
|
993
|
+
if (command === "deeplink" && (params.method === "simctl" || !activeTarget)) {
|
|
1084
994
|
return handleDeeplinkSimctl(params);
|
|
1085
995
|
}
|
|
1086
|
-
//
|
|
996
|
+
// Sequence: batch multiple commands in one call
|
|
997
|
+
if (command === "sequence") {
|
|
998
|
+
const steps = params.steps;
|
|
999
|
+
if (!steps || steps.length === 0) {
|
|
1000
|
+
return {
|
|
1001
|
+
content: [{ type: "text", text: "Error: 'steps' array is required and must not be empty" }],
|
|
1002
|
+
isError: true,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
const results = [];
|
|
1006
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1007
|
+
const step = steps[i];
|
|
1008
|
+
const stepParams = step.params ?? {};
|
|
1009
|
+
try {
|
|
1010
|
+
const result = await send(step.command, stepParams, targetId);
|
|
1011
|
+
results.push(`[${i + 1}] ${step.command}: ${result}`);
|
|
1012
|
+
}
|
|
1013
|
+
catch (e) {
|
|
1014
|
+
results.push(`[${i + 1}] ${step.command}: Error — ${e.message}`);
|
|
1015
|
+
// Stop on error — later steps likely depend on earlier ones
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return { content: [{ type: "text", text: results.join("\n\n") }] };
|
|
1020
|
+
}
|
|
1021
|
+
// Special case: status
|
|
1087
1022
|
if (command === "status") {
|
|
1088
|
-
|
|
1089
|
-
const targets = connection.getConnectedTargets();
|
|
1090
|
-
if (targets.length === 0) {
|
|
1023
|
+
if (!activeTarget) {
|
|
1091
1024
|
return {
|
|
1092
1025
|
content: [
|
|
1093
1026
|
{
|
|
1094
1027
|
type: "text",
|
|
1095
|
-
text: "No
|
|
1028
|
+
text: "No active target.\n\nMake sure your iOS app includes the Nerve SPM package:\n #if DEBUG\n import Nerve\n Nerve.start()\n #endif\n\nThen build and run with nerve_run.",
|
|
1096
1029
|
},
|
|
1097
1030
|
],
|
|
1098
1031
|
};
|
|
1099
1032
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}
|
|
1033
|
+
try {
|
|
1034
|
+
const result = await send("status", {}, targetId);
|
|
1035
|
+
return { content: [{ type: "text", text: result }] };
|
|
1036
|
+
}
|
|
1037
|
+
catch (e) {
|
|
1038
|
+
return {
|
|
1039
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
1040
|
+
isError: true,
|
|
1041
|
+
};
|
|
1110
1042
|
}
|
|
1111
|
-
return {
|
|
1112
|
-
content: [{ type: "text", text: results.join("\n\n") }],
|
|
1113
|
-
};
|
|
1114
1043
|
}
|
|
1115
1044
|
try {
|
|
1116
1045
|
// Remove device 'target' from params before forwarding
|
|
@@ -1119,7 +1048,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1119
1048
|
if (command === "navigate" && target_screen) {
|
|
1120
1049
|
commandParams.target = target_screen;
|
|
1121
1050
|
}
|
|
1122
|
-
const result = await
|
|
1051
|
+
const result = await send(command, commandParams, targetId);
|
|
1123
1052
|
// For screenshots, check if the result is base64 image data
|
|
1124
1053
|
if (command === "screenshot" && result.startsWith("data:image/")) {
|
|
1125
1054
|
const base64 = result.replace("data:image/png;base64,", "");
|
|
@@ -1162,6 +1091,60 @@ function runShell(cmd, timeoutMs = 120000) {
|
|
|
1162
1091
|
proc.on("error", reject);
|
|
1163
1092
|
});
|
|
1164
1093
|
}
|
|
1094
|
+
// --- Nerve Framework Injection ---
|
|
1095
|
+
// Resolve paths for finding the Nerve framework
|
|
1096
|
+
// mcp-server/dist/index.js -> mcp-server/ (npm package root)
|
|
1097
|
+
// mcp-server/src/index.ts -> mcp-server/ (development)
|
|
1098
|
+
const mpcPackageRoot = path.resolve(__dirname, "..");
|
|
1099
|
+
const nerveRepoRoot = path.resolve(mpcPackageRoot, "..");
|
|
1100
|
+
function findNerveFramework() {
|
|
1101
|
+
const candidates = [
|
|
1102
|
+
// 1. Bundled with npm package (npm install nerve-mcp)
|
|
1103
|
+
path.join(mpcPackageRoot, "framework", "Nerve.framework", "Nerve"),
|
|
1104
|
+
// 2. Homebrew installation
|
|
1105
|
+
"/opt/homebrew/lib/nerve/Nerve.framework/Nerve",
|
|
1106
|
+
"/usr/local/lib/nerve/Nerve.framework/Nerve",
|
|
1107
|
+
// 3. Repo .build/inject/ (development from source)
|
|
1108
|
+
path.join(nerveRepoRoot, ".build", "inject", "Nerve.framework", "Nerve"),
|
|
1109
|
+
];
|
|
1110
|
+
return candidates.find(p => fs.existsSync(p)) ?? null;
|
|
1111
|
+
}
|
|
1112
|
+
async function ensureNerveFramework() {
|
|
1113
|
+
const existing = findNerveFramework();
|
|
1114
|
+
if (existing)
|
|
1115
|
+
return existing;
|
|
1116
|
+
// Auto-build from source (development mode)
|
|
1117
|
+
const buildScript = path.join(nerveRepoRoot, "scripts", "build-framework.sh");
|
|
1118
|
+
if (fs.existsSync(buildScript)) {
|
|
1119
|
+
await runShell(`bash "${buildScript}"`, 180000);
|
|
1120
|
+
const built = findNerveFramework();
|
|
1121
|
+
if (built)
|
|
1122
|
+
return built;
|
|
1123
|
+
}
|
|
1124
|
+
throw new Error("Nerve.framework not found. If installed via npm, reinstall nerve-mcp. " +
|
|
1125
|
+
"If developing from source, run: scripts/build-framework.sh");
|
|
1126
|
+
}
|
|
1127
|
+
async function appContainsNerve(appPath) {
|
|
1128
|
+
// Check if the app binary contains Nerve symbols (works for both static and dynamic SPM linking)
|
|
1129
|
+
try {
|
|
1130
|
+
const appName = path.basename(appPath, ".app");
|
|
1131
|
+
const binary = path.join(appPath, appName);
|
|
1132
|
+
const symbols = await runShell(`nm -gU "${binary}" 2>/dev/null | grep nerve_auto_start || true`);
|
|
1133
|
+
if (symbols.trim())
|
|
1134
|
+
return true;
|
|
1135
|
+
// Also check Frameworks/ for dynamic linking case
|
|
1136
|
+
const frameworks = path.join(appPath, "Frameworks");
|
|
1137
|
+
if (fs.existsSync(frameworks)) {
|
|
1138
|
+
const entries = fs.readdirSync(frameworks);
|
|
1139
|
+
if (entries.some(e => e.startsWith("Nerve")))
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1165
1148
|
async function findSimulatorUDID(name) {
|
|
1166
1149
|
const json = await runShell("xcrun simctl list devices available -j");
|
|
1167
1150
|
const data = JSON.parse(json);
|
|
@@ -1192,10 +1175,24 @@ async function handleBuildRun(command, params) {
|
|
|
1192
1175
|
buildSource = `-workspace "${workspace}"`;
|
|
1193
1176
|
else if (project)
|
|
1194
1177
|
buildSource = `-project "${project}"`;
|
|
1195
|
-
|
|
1196
|
-
const
|
|
1178
|
+
// Per-project derived data to avoid cross-project collisions
|
|
1179
|
+
const projectDir = workspace ? path.dirname(path.resolve(workspace)) : project ? path.dirname(path.resolve(project)) : process.cwd();
|
|
1180
|
+
const projectName = path.basename(projectDir);
|
|
1181
|
+
const derivedData = `/tmp/nerve-derived-data-${projectName}`;
|
|
1182
|
+
const buildCmd = `set -o pipefail && xcodebuild build ${buildSource} -scheme "${scheme}" -sdk iphonesimulator -derivedDataPath "${derivedData}" -quiet 2>&1 | tail -20`;
|
|
1197
1183
|
log.push(`Building ${scheme} for simulator...`);
|
|
1198
|
-
|
|
1184
|
+
let buildOutput;
|
|
1185
|
+
try {
|
|
1186
|
+
buildOutput = await runShell(buildCmd, 300000);
|
|
1187
|
+
}
|
|
1188
|
+
catch (e) {
|
|
1189
|
+
const errMsg = e.message;
|
|
1190
|
+
log.push(errMsg);
|
|
1191
|
+
return {
|
|
1192
|
+
content: [{ type: "text", text: log.join("\n") }],
|
|
1193
|
+
isError: true,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1199
1196
|
if (buildOutput.trim())
|
|
1200
1197
|
log.push(buildOutput.trim());
|
|
1201
1198
|
log.push("Build succeeded.");
|
|
@@ -1203,8 +1200,12 @@ async function handleBuildRun(command, params) {
|
|
|
1203
1200
|
return { content: [{ type: "text", text: log.join("\n") }] };
|
|
1204
1201
|
}
|
|
1205
1202
|
// --- Run: install + launch with Nerve injection ---
|
|
1206
|
-
//
|
|
1207
|
-
const
|
|
1203
|
+
// Get exact app path from build settings (no guessing with find)
|
|
1204
|
+
const settingsCmd = `xcodebuild -showBuildSettings ${buildSource} -scheme "${scheme}" -sdk iphonesimulator -derivedDataPath "${derivedData}" 2>/dev/null`;
|
|
1205
|
+
const settings = await runShell(settingsCmd);
|
|
1206
|
+
const builtProductsDir = settings.match(/^\s*BUILT_PRODUCTS_DIR = (.+)/m)?.[1]?.trim();
|
|
1207
|
+
const productName = settings.match(/^\s*FULL_PRODUCT_NAME = (.+)/m)?.[1]?.trim();
|
|
1208
|
+
const appPath = builtProductsDir && productName ? `${builtProductsDir}/${productName}` : "";
|
|
1208
1209
|
if (!appPath) {
|
|
1209
1210
|
return {
|
|
1210
1211
|
content: [{ type: "text", text: log.join("\n") + "\nError: Could not find .app bundle" }],
|
|
@@ -1234,28 +1235,215 @@ async function handleBuildRun(command, params) {
|
|
|
1234
1235
|
catch {
|
|
1235
1236
|
// Not running
|
|
1236
1237
|
}
|
|
1237
|
-
//
|
|
1238
|
-
|
|
1239
|
-
|
|
1238
|
+
// Launch — detect SPM vs inject mode
|
|
1239
|
+
const hasNerve = await appContainsNerve(appPath);
|
|
1240
|
+
let injected = false;
|
|
1241
|
+
if (hasNerve) {
|
|
1242
|
+
await runShell(`xcrun simctl launch "${udid}" "${bundleId}"`);
|
|
1243
|
+
log.push("Launched (SPM mode).");
|
|
1240
1244
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1245
|
+
else {
|
|
1246
|
+
const frameworkBinary = await ensureNerveFramework();
|
|
1247
|
+
await runShell(`SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="${frameworkBinary}" xcrun simctl launch "${udid}" "${bundleId}"`);
|
|
1248
|
+
log.push("Launched (inject mode).");
|
|
1249
|
+
injected = true;
|
|
1250
|
+
}
|
|
1251
|
+
// Set active target
|
|
1252
|
+
activeTarget = { udid, bundleId };
|
|
1253
|
+
const port = nervePort(udid, bundleId);
|
|
1254
|
+
// Wait for Nerve to be ready: TCP probe on calculated port
|
|
1255
|
+
let nerveReady = false;
|
|
1256
|
+
for (let i = 0; i < 40; i++) {
|
|
1257
|
+
if (await tcpProbe("127.0.0.1", port, 500)) {
|
|
1258
|
+
log.push(`Nerve ready on port ${port}`);
|
|
1259
|
+
nerveReady = true;
|
|
1252
1260
|
break;
|
|
1253
1261
|
}
|
|
1254
|
-
await new Promise(r => setTimeout(r,
|
|
1262
|
+
await new Promise(r => setTimeout(r, 250));
|
|
1263
|
+
}
|
|
1264
|
+
if (!nerveReady) {
|
|
1265
|
+
if (injected) {
|
|
1266
|
+
log.push("Nerve did not start after injection. The framework may be incompatible — try rebuilding: delete .build/inject/ and re-run.");
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
log.push("Nerve did not start. Ensure your app calls Nerve.start() in #if DEBUG.");
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return { content: [{ type: "text", text: log.join("\n") }] };
|
|
1273
|
+
}
|
|
1274
|
+
catch (e) {
|
|
1275
|
+
const error = e;
|
|
1276
|
+
log.push(`Error: ${error.message}`);
|
|
1277
|
+
return {
|
|
1278
|
+
content: [{ type: "text", text: log.join("\n") }],
|
|
1279
|
+
isError: true,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// --- Appearance (Mac-side + in-app fallback) ---
|
|
1284
|
+
async function handleAppearance(params, targetId) {
|
|
1285
|
+
const mode = params.mode;
|
|
1286
|
+
if (!mode || !["dark", "light", "toggle"].includes(mode)) {
|
|
1287
|
+
return {
|
|
1288
|
+
content: [{ type: "text", text: "Error: 'mode' must be 'dark', 'light', or 'toggle'" }],
|
|
1289
|
+
isError: true,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
// Try simctl first (simulator)
|
|
1293
|
+
if (activeTarget) {
|
|
1294
|
+
try {
|
|
1295
|
+
if (mode === "toggle") {
|
|
1296
|
+
// Read current, then flip
|
|
1297
|
+
const current = (await runShell(`xcrun simctl ui "${activeTarget.udid}" appearance 2>/dev/null`)).trim();
|
|
1298
|
+
const newMode = current === "dark" ? "light" : "dark";
|
|
1299
|
+
await runShell(`xcrun simctl ui "${activeTarget.udid}" appearance ${newMode}`);
|
|
1300
|
+
return { content: [{ type: "text", text: `Switched to ${newMode} mode (was ${current})` }] };
|
|
1301
|
+
}
|
|
1302
|
+
await runShell(`xcrun simctl ui "${activeTarget.udid}" appearance ${mode}`);
|
|
1303
|
+
return { content: [{ type: "text", text: `Switched to ${mode} mode` }] };
|
|
1304
|
+
}
|
|
1305
|
+
catch {
|
|
1306
|
+
// Not a simulator — fall through to in-app override
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
// Fallback: override via Nerve in-app (works on device too)
|
|
1310
|
+
try {
|
|
1311
|
+
let style;
|
|
1312
|
+
if (mode === "toggle") {
|
|
1313
|
+
// Ask the app for current style, then flip
|
|
1314
|
+
const current = await send("modify", {
|
|
1315
|
+
query: ".UIWindow:0",
|
|
1316
|
+
property: "overrideUserInterfaceStyle",
|
|
1317
|
+
}, targetId);
|
|
1318
|
+
// 0 = unspecified, 1 = light, 2 = dark
|
|
1319
|
+
style = current.includes("2") ? "1" : "2";
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
style = mode === "dark" ? "2" : "1";
|
|
1323
|
+
}
|
|
1324
|
+
const result = await send("modify", {
|
|
1325
|
+
query: ".UIWindow:0",
|
|
1326
|
+
property: "overrideUserInterfaceStyle",
|
|
1327
|
+
value: parseInt(style),
|
|
1328
|
+
}, targetId);
|
|
1329
|
+
const label = style === "2" ? "dark" : "light";
|
|
1330
|
+
return { content: [{ type: "text", text: `Switched to ${label} mode (in-app override)\n${result}` }] };
|
|
1331
|
+
}
|
|
1332
|
+
catch (e) {
|
|
1333
|
+
return {
|
|
1334
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
1335
|
+
isError: true,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// --- Physical Device (Mac-side) ---
|
|
1340
|
+
async function handleListDevices() {
|
|
1341
|
+
try {
|
|
1342
|
+
const output = await runShell("xcrun devicectl list devices 2>&1");
|
|
1343
|
+
return { content: [{ type: "text", text: output.trim() }] };
|
|
1344
|
+
}
|
|
1345
|
+
catch (e) {
|
|
1346
|
+
return {
|
|
1347
|
+
content: [{ type: "text", text: `Error listing devices: ${e.message}` }],
|
|
1348
|
+
isError: true,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async function findDeviceIdentifier(nameOrUDID) {
|
|
1353
|
+
const output = await runShell("xcrun devicectl list devices -j 2>/dev/null");
|
|
1354
|
+
const data = JSON.parse(output);
|
|
1355
|
+
const devices = data?.result?.devices ?? [];
|
|
1356
|
+
for (const d of devices) {
|
|
1357
|
+
if (d.connectionProperties?.transportType !== "wired" && d.connectionProperties?.transportType !== "localNetwork")
|
|
1358
|
+
continue;
|
|
1359
|
+
const devName = d.deviceProperties?.name ?? "";
|
|
1360
|
+
const devId = d.hardwareProperties?.udid ?? d.identifier ?? "";
|
|
1361
|
+
const coreId = d.identifier ?? "";
|
|
1362
|
+
if (nameOrUDID) {
|
|
1363
|
+
if (devName === nameOrUDID || devId === nameOrUDID || coreId === nameOrUDID) {
|
|
1364
|
+
return { identifier: coreId, name: devName };
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
// Return first available paired device
|
|
1369
|
+
if (d.connectionProperties?.pairingState === "paired") {
|
|
1370
|
+
return { identifier: coreId, name: devName };
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
throw new Error(nameOrUDID
|
|
1375
|
+
? `Device '${nameOrUDID}' not found or not connected. Run nerve_list_devices to see available devices.`
|
|
1376
|
+
: "No paired device found. Connect a device and run nerve_list_devices.");
|
|
1377
|
+
}
|
|
1378
|
+
async function handleRunDevice(params) {
|
|
1379
|
+
const scheme = params.scheme;
|
|
1380
|
+
const workspace = params.workspace;
|
|
1381
|
+
const project = params.project;
|
|
1382
|
+
const device = params.device;
|
|
1383
|
+
const team = params.team;
|
|
1384
|
+
if (!scheme) {
|
|
1385
|
+
return {
|
|
1386
|
+
content: [{ type: "text", text: "Error: 'scheme' parameter is required" }],
|
|
1387
|
+
isError: true,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
const log = [];
|
|
1391
|
+
try {
|
|
1392
|
+
// Find the device
|
|
1393
|
+
const dev = await findDeviceIdentifier(device);
|
|
1394
|
+
log.push(`Device: ${dev.name} (${dev.identifier})`);
|
|
1395
|
+
// Build for device
|
|
1396
|
+
let buildSource = "";
|
|
1397
|
+
if (workspace)
|
|
1398
|
+
buildSource = `-workspace "${workspace}"`;
|
|
1399
|
+
else if (project)
|
|
1400
|
+
buildSource = `-project "${project}"`;
|
|
1401
|
+
const projectDir = workspace ? path.dirname(path.resolve(workspace)) : project ? path.dirname(path.resolve(project)) : process.cwd();
|
|
1402
|
+
const projectName = path.basename(projectDir);
|
|
1403
|
+
const derivedData = `/tmp/nerve-derived-data-${projectName}`;
|
|
1404
|
+
let signingFlags = "";
|
|
1405
|
+
if (team) {
|
|
1406
|
+
signingFlags = `DEVELOPMENT_TEAM="${team}" CODE_SIGN_STYLE=Automatic`;
|
|
1255
1407
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1408
|
+
const buildCmd = `set -o pipefail && xcodebuild build ${buildSource} -scheme "${scheme}" -sdk iphoneos -destination "generic/platform=iOS" -derivedDataPath "${derivedData}" -allowProvisioningUpdates ${signingFlags} -quiet 2>&1 | tail -30`;
|
|
1409
|
+
log.push(`Building ${scheme} for device...`);
|
|
1410
|
+
let buildOutput;
|
|
1411
|
+
try {
|
|
1412
|
+
buildOutput = await runShell(buildCmd, 300000);
|
|
1413
|
+
}
|
|
1414
|
+
catch (e) {
|
|
1415
|
+
const errMsg = e.message;
|
|
1416
|
+
log.push(errMsg);
|
|
1417
|
+
return {
|
|
1418
|
+
content: [{ type: "text", text: log.join("\n") }],
|
|
1419
|
+
isError: true,
|
|
1420
|
+
};
|
|
1258
1421
|
}
|
|
1422
|
+
if (buildOutput.trim())
|
|
1423
|
+
log.push(buildOutput.trim());
|
|
1424
|
+
log.push("Build succeeded.");
|
|
1425
|
+
// Find the .app bundle
|
|
1426
|
+
const settingsCmd = `xcodebuild -showBuildSettings ${buildSource} -scheme "${scheme}" -sdk iphoneos -derivedDataPath "${derivedData}" 2>/dev/null`;
|
|
1427
|
+
const settings = await runShell(settingsCmd);
|
|
1428
|
+
const builtProductsDir = settings.match(/^\s*BUILT_PRODUCTS_DIR = (.+)/m)?.[1]?.trim();
|
|
1429
|
+
const productName = settings.match(/^\s*FULL_PRODUCT_NAME = (.+)/m)?.[1]?.trim();
|
|
1430
|
+
const appPath = builtProductsDir && productName ? `${builtProductsDir}/${productName}` : "";
|
|
1431
|
+
if (!appPath) {
|
|
1432
|
+
return {
|
|
1433
|
+
content: [{ type: "text", text: log.join("\n") + "\nError: Could not find .app bundle" }],
|
|
1434
|
+
isError: true,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
const bundleId = (await runShell(`/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`)).trim();
|
|
1438
|
+
log.push(`App: ${bundleId}`);
|
|
1439
|
+
// Install on device
|
|
1440
|
+
log.push("Installing on device...");
|
|
1441
|
+
await runShell(`xcrun devicectl device install app --device "${dev.identifier}" "${appPath}" 2>&1`, 120000);
|
|
1442
|
+
log.push("Installed.");
|
|
1443
|
+
// Launch on device
|
|
1444
|
+
log.push("Launching...");
|
|
1445
|
+
await runShell(`xcrun devicectl device process launch --device "${dev.identifier}" "${bundleId}" 2>&1`, 30000);
|
|
1446
|
+
log.push("Launched.");
|
|
1259
1447
|
return { content: [{ type: "text", text: log.join("\n") }] };
|
|
1260
1448
|
}
|
|
1261
1449
|
catch (e) {
|
|
@@ -1276,32 +1464,13 @@ async function handleGrantPermissions(params) {
|
|
|
1276
1464
|
isError: true,
|
|
1277
1465
|
};
|
|
1278
1466
|
}
|
|
1279
|
-
|
|
1280
|
-
const targets = connection.getConnectedTargets();
|
|
1281
|
-
let udid;
|
|
1282
|
-
let bundleId;
|
|
1283
|
-
if (targets.length > 0) {
|
|
1284
|
-
udid = targets[0].udid;
|
|
1285
|
-
bundleId = targets[0].bundleId;
|
|
1286
|
-
}
|
|
1287
|
-
else {
|
|
1288
|
-
// Try to find from port files
|
|
1289
|
-
const dir = "/tmp/nerve-ports";
|
|
1290
|
-
if (fs.existsSync(dir)) {
|
|
1291
|
-
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
1292
|
-
if (files.length > 0) {
|
|
1293
|
-
const info = JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf-8"));
|
|
1294
|
-
udid = info.udid;
|
|
1295
|
-
bundleId = info.bundleId;
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
if (!udid || !bundleId) {
|
|
1467
|
+
if (!activeTarget) {
|
|
1300
1468
|
return {
|
|
1301
|
-
content: [{ type: "text", text: "Error: No
|
|
1469
|
+
content: [{ type: "text", text: "Error: No active target. Launch the app first with nerve_run." }],
|
|
1302
1470
|
isError: true,
|
|
1303
1471
|
};
|
|
1304
1472
|
}
|
|
1473
|
+
const { udid, bundleId } = activeTarget;
|
|
1305
1474
|
const log = [];
|
|
1306
1475
|
for (const service of services) {
|
|
1307
1476
|
try {
|
|
@@ -1323,22 +1492,7 @@ async function handleDeeplinkSimctl(params) {
|
|
|
1323
1492
|
isError: true,
|
|
1324
1493
|
};
|
|
1325
1494
|
}
|
|
1326
|
-
|
|
1327
|
-
let udid;
|
|
1328
|
-
const targets = connection.getConnectedTargets();
|
|
1329
|
-
if (targets.length > 0) {
|
|
1330
|
-
udid = targets[0].udid;
|
|
1331
|
-
}
|
|
1332
|
-
else {
|
|
1333
|
-
const dir = "/tmp/nerve-ports";
|
|
1334
|
-
if (fs.existsSync(dir)) {
|
|
1335
|
-
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
1336
|
-
if (files.length > 0) {
|
|
1337
|
-
const info = JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf-8"));
|
|
1338
|
-
udid = info.udid;
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1495
|
+
let udid = activeTarget?.udid;
|
|
1342
1496
|
if (!udid) {
|
|
1343
1497
|
// Try booted simulators
|
|
1344
1498
|
try {
|
|
@@ -1375,14 +1529,9 @@ async function handleDeeplinkSimctl(params) {
|
|
|
1375
1529
|
}
|
|
1376
1530
|
// --- Main ---
|
|
1377
1531
|
async function main() {
|
|
1378
|
-
// Initial discovery
|
|
1379
|
-
await connection.discover();
|
|
1380
|
-
// Start MCP server on stdio
|
|
1381
1532
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
1382
1533
|
await server.connect(transport);
|
|
1383
1534
|
console.error("[nerve] MCP server started");
|
|
1384
|
-
// Periodic re-discovery
|
|
1385
|
-
setInterval(() => connection.discover(), 3000);
|
|
1386
1535
|
}
|
|
1387
1536
|
main().catch((e) => {
|
|
1388
1537
|
console.error("[nerve] Fatal:", e);
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nerve-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for Nerve — gives AI agents runtime access to iOS apps",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"start": "node dist/index.js",
|
|
12
12
|
"dev": "tsx src/index.ts",
|
|
13
|
-
"prepublishOnly": "npm run build"
|
|
13
|
+
"prepublishOnly": "npm run build && npm run bundle-framework",
|
|
14
|
+
"bundle-framework": "bash ../scripts/build-framework.sh && rm -rf framework && mkdir -p framework/Nerve.framework && cp ../.build/inject/Nerve.framework/Nerve framework/Nerve.framework/ && cp ../.build/inject/Nerve.framework/Info.plist framework/Nerve.framework/"
|
|
14
15
|
},
|
|
15
16
|
"repository": {
|
|
16
17
|
"type": "git",
|
|
17
18
|
"url": "git+https://github.com/luchi0208/nerve-ios.git",
|
|
18
19
|
"directory": "mcp-server"
|
|
19
20
|
},
|
|
20
|
-
"files": ["dist", "README.md"],
|
|
21
|
+
"files": ["dist", "framework", "README.md"],
|
|
21
22
|
"keywords": ["mcp", "ios", "automation", "simulator", "nerve"],
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@modelcontextprotocol/sdk": "^1.0.0",
|