nerve-mcp 0.1.0 → 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/dist/index.js +413 -337
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -42,253 +42,94 @@ 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
|
-
|
|
53
|
-
for (
|
|
54
|
-
|
|
55
|
-
continue;
|
|
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 net = __importStar(require("net"));
|
|
47
|
+
// --- Port Resolution ---
|
|
48
|
+
// djb2 hash — must match the Swift side exactly
|
|
49
|
+
function nervePort(udid, bundleId) {
|
|
50
|
+
const key = `${udid}-${bundleId}`;
|
|
51
|
+
let hash = 5381;
|
|
52
|
+
for (let i = 0; i < key.length; i++) {
|
|
53
|
+
hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0; // djb2, unsigned 32-bit
|
|
81
54
|
}
|
|
82
|
-
return
|
|
55
|
+
return 10000 + (hash % 55000);
|
|
83
56
|
}
|
|
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
|
-
}
|
|
57
|
+
// Track the active target (set by nerve_run, or auto-detected from booted sims)
|
|
58
|
+
let activeTarget = null;
|
|
59
|
+
let requestCounter = 0;
|
|
60
|
+
function resolvePort(targetId) {
|
|
61
|
+
if (targetId) {
|
|
62
|
+
// targetId format: "sim:{udid}:{bundleId}"
|
|
63
|
+
const parts = targetId.split(":");
|
|
64
|
+
if (parts.length >= 3 && parts[0] === "sim") {
|
|
65
|
+
return nervePort(parts[1], parts.slice(2).join(":"));
|
|
96
66
|
}
|
|
97
|
-
|
|
67
|
+
throw new Error(`Unknown target format: ${targetId}`);
|
|
98
68
|
}
|
|
99
|
-
|
|
100
|
-
|
|
69
|
+
if (!activeTarget) {
|
|
70
|
+
throw new Error("No active target. Use nerve_run to launch an app first, or specify a target.");
|
|
101
71
|
}
|
|
72
|
+
return nervePort(activeTarget.udid, activeTarget.bundleId);
|
|
102
73
|
}
|
|
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}`;
|
|
74
|
+
async function send(command, params = {}, targetId, timeoutMs = 30000) {
|
|
75
|
+
const port = resolvePort(targetId);
|
|
76
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
77
|
+
const id = `req_${++requestCounter}`;
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
143
79
|
const ws = new ws_1.default(url);
|
|
144
|
-
let
|
|
80
|
+
let settled = false;
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
if (!settled) {
|
|
83
|
+
settled = true;
|
|
84
|
+
ws.terminate();
|
|
85
|
+
reject(new Error(`Command '${command}' timed out after ${timeoutMs / 1000}s`));
|
|
86
|
+
}
|
|
87
|
+
}, timeoutMs);
|
|
145
88
|
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);
|
|
89
|
+
ws.send(JSON.stringify({ id, command, params }));
|
|
163
90
|
});
|
|
164
91
|
ws.on("message", (data) => {
|
|
165
92
|
try {
|
|
166
93
|
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
|
-
}
|
|
94
|
+
if (response.id === id && !settled) {
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
ws.close();
|
|
98
|
+
if (response.ok)
|
|
99
|
+
resolve(response.data);
|
|
100
|
+
else
|
|
101
|
+
reject(new Error(response.data));
|
|
177
102
|
}
|
|
178
103
|
}
|
|
179
|
-
catch {
|
|
180
|
-
|
|
104
|
+
catch { /* ignore malformed */ }
|
|
105
|
+
});
|
|
106
|
+
ws.on("error", (err) => {
|
|
107
|
+
if (!settled) {
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
reject(new Error(`Connection error: ${err.message}`));
|
|
181
111
|
}
|
|
182
112
|
});
|
|
183
113
|
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
|
-
}
|
|
114
|
+
if (!settled) {
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
reject(new Error("Connection closed before response"));
|
|
237
118
|
}
|
|
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
119
|
});
|
|
288
|
-
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function tcpProbe(host, port, timeoutMs) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const socket = new net.Socket();
|
|
125
|
+
socket.setTimeout(timeoutMs);
|
|
126
|
+
socket.on("connect", () => { socket.destroy(); resolve(true); });
|
|
127
|
+
socket.on("error", () => { socket.destroy(); resolve(false); });
|
|
128
|
+
socket.on("timeout", () => { socket.destroy(); resolve(false); });
|
|
129
|
+
socket.connect(port, host);
|
|
130
|
+
});
|
|
289
131
|
}
|
|
290
132
|
// --- MCP Server ---
|
|
291
|
-
const connection = new NerveConnection();
|
|
292
133
|
const server = new index_js_1.Server({ name: "nerve", version: "0.1.0" }, {
|
|
293
134
|
capabilities: { tools: {} },
|
|
294
135
|
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 +161,7 @@ The tap= coordinate is the center point where the element is reliably hittable.
|
|
|
320
161
|
- nerve_back to go back, nerve_dismiss to close modals/keyboard.
|
|
321
162
|
- nerve_map to see all discovered screens. nerve_navigate to auto-navigate to a known screen.
|
|
322
163
|
- nerve_deeplink to open a URL scheme directly.
|
|
323
|
-
-
|
|
164
|
+
- Interaction commands (tap, scroll, type, swipe, back, dismiss) automatically return the updated screen state — no need to call nerve_view after them.
|
|
324
165
|
|
|
325
166
|
### Interact
|
|
326
167
|
- nerve_tap to press buttons and select items. Use @eN refs or #id.
|
|
@@ -334,14 +175,15 @@ The tap= coordinate is the center point where the element is reliably hittable.
|
|
|
334
175
|
- Note: auto-wait after actions only covers UI settling (animations, transitions). For network completion, use nerve_wait_idle or nerve_network explicitly.
|
|
335
176
|
|
|
336
177
|
### Verify
|
|
337
|
-
- nerve_view to see updated screen state.
|
|
178
|
+
- 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
179
|
- nerve_console with filter="[nerve]" and since="last_action" for your trace logs.
|
|
339
|
-
- nerve_screenshot
|
|
180
|
+
- nerve_screenshot ONLY when you need to verify visual layout, colors, or spatial relationships that text can't convey. Do NOT use screenshot as a substitute for nerve_view.
|
|
340
181
|
- nerve_heap to inspect live objects (e.g., check ViewModel state).
|
|
341
182
|
|
|
342
183
|
### Tips
|
|
343
184
|
- Always call nerve_view before interacting — don't guess element identifiers.
|
|
344
185
|
- Use @eN refs from nerve_view output to tap elements without identifiers.
|
|
186
|
+
- nerve_view is lightweight (~1 line per element) and gives you everything needed to interact. Prefer it over nerve_screenshot for all inspection tasks.
|
|
345
187
|
- If an element isn't visible, try nerve_scroll_to_find before giving up.
|
|
346
188
|
- The navigation map builds automatically and persists across sessions.
|
|
347
189
|
- Do NOT add sleep/delay between commands — Nerve handles waiting automatically.
|
|
@@ -384,7 +226,7 @@ const TOOLS = [
|
|
|
384
226
|
},
|
|
385
227
|
{
|
|
386
228
|
name: "nerve_tap",
|
|
387
|
-
description: "Tap a UI element
|
|
229
|
+
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
230
|
inputSchema: {
|
|
389
231
|
type: "object",
|
|
390
232
|
properties: {
|
|
@@ -529,12 +371,13 @@ const TOOLS = [
|
|
|
529
371
|
},
|
|
530
372
|
{
|
|
531
373
|
name: "nerve_screenshot",
|
|
532
|
-
description: "Capture a screenshot of the current screen. Returns base64-encoded PNG.",
|
|
374
|
+
description: "Capture a screenshot of the current screen. Returns base64-encoded PNG. Prefer nerve_view for understanding screen state and finding elements — it returns structured data with element refs and tap coordinates. Only use screenshot for visual layout verification when text output isn't enough.",
|
|
533
375
|
inputSchema: {
|
|
534
376
|
type: "object",
|
|
535
377
|
properties: {
|
|
536
378
|
target: { type: "string" },
|
|
537
379
|
scale: { type: "number", description: "Image scale. Default: 1.0." },
|
|
380
|
+
maxDimension: { type: "number", description: "Resize so longest side fits within this value (in points). Normalizes across device sizes. Example: 800. Overrides scale when set." },
|
|
538
381
|
},
|
|
539
382
|
},
|
|
540
383
|
},
|
|
@@ -749,6 +592,41 @@ const TOOLS = [
|
|
|
749
592
|
required: ["simulator"],
|
|
750
593
|
},
|
|
751
594
|
},
|
|
595
|
+
{
|
|
596
|
+
name: "nerve_appearance",
|
|
597
|
+
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.",
|
|
598
|
+
inputSchema: {
|
|
599
|
+
type: "object",
|
|
600
|
+
properties: {
|
|
601
|
+
mode: { type: "string", enum: ["dark", "light", "toggle"], description: "Appearance mode. 'toggle' switches from current." },
|
|
602
|
+
target: { type: "string" },
|
|
603
|
+
},
|
|
604
|
+
required: ["mode"],
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
name: "nerve_run_device",
|
|
609
|
+
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.",
|
|
610
|
+
inputSchema: {
|
|
611
|
+
type: "object",
|
|
612
|
+
properties: {
|
|
613
|
+
scheme: { type: "string", description: "Xcode scheme to build." },
|
|
614
|
+
workspace: { type: "string", description: "Path to .xcworkspace (optional)." },
|
|
615
|
+
project: { type: "string", description: "Path to .xcodeproj (optional)." },
|
|
616
|
+
device: { type: "string", description: "Device name or UDID. If omitted, uses the first available paired device." },
|
|
617
|
+
team: { type: "string", description: "Apple Development Team ID for code signing (optional if set in Xcode project)." },
|
|
618
|
+
},
|
|
619
|
+
required: ["scheme"],
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: "nerve_list_devices",
|
|
624
|
+
description: "List connected physical iOS devices and their state (available/unavailable).",
|
|
625
|
+
inputSchema: {
|
|
626
|
+
type: "object",
|
|
627
|
+
properties: {},
|
|
628
|
+
},
|
|
629
|
+
},
|
|
752
630
|
{
|
|
753
631
|
name: "nerve_trace",
|
|
754
632
|
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 +687,36 @@ const TOOLS = [
|
|
|
809
687
|
required: [],
|
|
810
688
|
},
|
|
811
689
|
},
|
|
690
|
+
{
|
|
691
|
+
name: "nerve_sequence",
|
|
692
|
+
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).",
|
|
693
|
+
inputSchema: {
|
|
694
|
+
type: "object",
|
|
695
|
+
properties: {
|
|
696
|
+
target: { type: "string", description: "Target ID. Auto-selects if only one connected." },
|
|
697
|
+
steps: {
|
|
698
|
+
type: "array",
|
|
699
|
+
description: "Array of commands to execute in order.",
|
|
700
|
+
items: {
|
|
701
|
+
type: "object",
|
|
702
|
+
properties: {
|
|
703
|
+
command: {
|
|
704
|
+
type: "string",
|
|
705
|
+
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",
|
|
706
|
+
},
|
|
707
|
+
params: {
|
|
708
|
+
type: "object",
|
|
709
|
+
description: "Parameters for the command (e.g., {\"query\": \"#login-btn\"} for tap, {\"text\": \"hello\"} for type).",
|
|
710
|
+
additionalProperties: true,
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
required: ["command"],
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
required: ["steps"],
|
|
718
|
+
},
|
|
719
|
+
},
|
|
812
720
|
];
|
|
813
721
|
// --- LLDB Session (Mac-side) ---
|
|
814
722
|
class LLDBSession {
|
|
@@ -930,35 +838,24 @@ async function handleLLDB(params) {
|
|
|
930
838
|
}
|
|
931
839
|
// Auto-attach if not already connected
|
|
932
840
|
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
|
-
}
|
|
841
|
+
if (!activeTarget) {
|
|
842
|
+
return {
|
|
843
|
+
content: [{ type: "text", text: "Error: No running app found. Launch the app first with nerve_run." }],
|
|
844
|
+
isError: true,
|
|
845
|
+
};
|
|
942
846
|
}
|
|
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
|
-
}
|
|
847
|
+
// Find PID by bundle ID from simctl
|
|
848
|
+
let pid;
|
|
849
|
+
try {
|
|
850
|
+
const out = await runShell(`xcrun simctl spawn "${activeTarget.udid}" launchctl list 2>/dev/null | grep "${activeTarget.bundleId}" | awk '{print $1}'`);
|
|
851
|
+
const parsed = parseInt(out.trim());
|
|
852
|
+
if (!isNaN(parsed))
|
|
853
|
+
pid = parsed;
|
|
958
854
|
}
|
|
855
|
+
catch { }
|
|
959
856
|
if (!pid) {
|
|
960
857
|
return {
|
|
961
|
-
content: [{ type: "text", text: "Error:
|
|
858
|
+
content: [{ type: "text", text: "Error: Could not find running app process. Launch the app first with nerve_run." }],
|
|
962
859
|
isError: true,
|
|
963
860
|
};
|
|
964
861
|
}
|
|
@@ -1064,6 +961,15 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1064
961
|
if (command === "build" || command === "run") {
|
|
1065
962
|
return handleBuildRun(command, params);
|
|
1066
963
|
}
|
|
964
|
+
if (command === "run_device") {
|
|
965
|
+
return handleRunDevice(params);
|
|
966
|
+
}
|
|
967
|
+
if (command === "appearance") {
|
|
968
|
+
return handleAppearance(params, targetId);
|
|
969
|
+
}
|
|
970
|
+
if (command === "list_devices") {
|
|
971
|
+
return handleListDevices();
|
|
972
|
+
}
|
|
1067
973
|
// Grant permissions via simctl (Mac-side)
|
|
1068
974
|
if (command === "grant_permissions") {
|
|
1069
975
|
return handleGrantPermissions(params);
|
|
@@ -1080,37 +986,56 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1080
986
|
return handleBootSimulator(params);
|
|
1081
987
|
}
|
|
1082
988
|
// Deeplink via simctl (Mac-side, when method is "simctl")
|
|
1083
|
-
if (command === "deeplink" && (params.method === "simctl" || !
|
|
989
|
+
if (command === "deeplink" && (params.method === "simctl" || !activeTarget)) {
|
|
1084
990
|
return handleDeeplinkSimctl(params);
|
|
1085
991
|
}
|
|
1086
|
-
//
|
|
992
|
+
// Sequence: batch multiple commands in one call
|
|
993
|
+
if (command === "sequence") {
|
|
994
|
+
const steps = params.steps;
|
|
995
|
+
if (!steps || steps.length === 0) {
|
|
996
|
+
return {
|
|
997
|
+
content: [{ type: "text", text: "Error: 'steps' array is required and must not be empty" }],
|
|
998
|
+
isError: true,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const results = [];
|
|
1002
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1003
|
+
const step = steps[i];
|
|
1004
|
+
const stepParams = step.params ?? {};
|
|
1005
|
+
try {
|
|
1006
|
+
const result = await send(step.command, stepParams, targetId);
|
|
1007
|
+
results.push(`[${i + 1}] ${step.command}: ${result}`);
|
|
1008
|
+
}
|
|
1009
|
+
catch (e) {
|
|
1010
|
+
results.push(`[${i + 1}] ${step.command}: Error — ${e.message}`);
|
|
1011
|
+
// Stop on error — later steps likely depend on earlier ones
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return { content: [{ type: "text", text: results.join("\n\n") }] };
|
|
1016
|
+
}
|
|
1017
|
+
// Special case: status
|
|
1087
1018
|
if (command === "status") {
|
|
1088
|
-
|
|
1089
|
-
const targets = connection.getConnectedTargets();
|
|
1090
|
-
if (targets.length === 0) {
|
|
1019
|
+
if (!activeTarget) {
|
|
1091
1020
|
return {
|
|
1092
1021
|
content: [
|
|
1093
1022
|
{
|
|
1094
1023
|
type: "text",
|
|
1095
|
-
text: "No
|
|
1024
|
+
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
1025
|
},
|
|
1097
1026
|
],
|
|
1098
1027
|
};
|
|
1099
1028
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}
|
|
1029
|
+
try {
|
|
1030
|
+
const result = await send("status", {}, targetId);
|
|
1031
|
+
return { content: [{ type: "text", text: result }] };
|
|
1032
|
+
}
|
|
1033
|
+
catch (e) {
|
|
1034
|
+
return {
|
|
1035
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
1036
|
+
isError: true,
|
|
1037
|
+
};
|
|
1110
1038
|
}
|
|
1111
|
-
return {
|
|
1112
|
-
content: [{ type: "text", text: results.join("\n\n") }],
|
|
1113
|
-
};
|
|
1114
1039
|
}
|
|
1115
1040
|
try {
|
|
1116
1041
|
// Remove device 'target' from params before forwarding
|
|
@@ -1119,7 +1044,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1119
1044
|
if (command === "navigate" && target_screen) {
|
|
1120
1045
|
commandParams.target = target_screen;
|
|
1121
1046
|
}
|
|
1122
|
-
const result = await
|
|
1047
|
+
const result = await send(command, commandParams, targetId);
|
|
1123
1048
|
// For screenshots, check if the result is base64 image data
|
|
1124
1049
|
if (command === "screenshot" && result.startsWith("data:image/")) {
|
|
1125
1050
|
const base64 = result.replace("data:image/png;base64,", "");
|
|
@@ -1192,10 +1117,24 @@ async function handleBuildRun(command, params) {
|
|
|
1192
1117
|
buildSource = `-workspace "${workspace}"`;
|
|
1193
1118
|
else if (project)
|
|
1194
1119
|
buildSource = `-project "${project}"`;
|
|
1195
|
-
|
|
1196
|
-
const
|
|
1120
|
+
// Per-project derived data to avoid cross-project collisions
|
|
1121
|
+
const projectDir = workspace ? path.dirname(path.resolve(workspace)) : project ? path.dirname(path.resolve(project)) : process.cwd();
|
|
1122
|
+
const projectName = path.basename(projectDir);
|
|
1123
|
+
const derivedData = `/tmp/nerve-derived-data-${projectName}`;
|
|
1124
|
+
const buildCmd = `set -o pipefail && xcodebuild build ${buildSource} -scheme "${scheme}" -sdk iphonesimulator -derivedDataPath "${derivedData}" -quiet 2>&1 | tail -20`;
|
|
1197
1125
|
log.push(`Building ${scheme} for simulator...`);
|
|
1198
|
-
|
|
1126
|
+
let buildOutput;
|
|
1127
|
+
try {
|
|
1128
|
+
buildOutput = await runShell(buildCmd, 300000);
|
|
1129
|
+
}
|
|
1130
|
+
catch (e) {
|
|
1131
|
+
const errMsg = e.message;
|
|
1132
|
+
log.push(errMsg);
|
|
1133
|
+
return {
|
|
1134
|
+
content: [{ type: "text", text: log.join("\n") }],
|
|
1135
|
+
isError: true,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1199
1138
|
if (buildOutput.trim())
|
|
1200
1139
|
log.push(buildOutput.trim());
|
|
1201
1140
|
log.push("Build succeeded.");
|
|
@@ -1203,8 +1142,12 @@ async function handleBuildRun(command, params) {
|
|
|
1203
1142
|
return { content: [{ type: "text", text: log.join("\n") }] };
|
|
1204
1143
|
}
|
|
1205
1144
|
// --- Run: install + launch with Nerve injection ---
|
|
1206
|
-
//
|
|
1207
|
-
const
|
|
1145
|
+
// Get exact app path from build settings (no guessing with find)
|
|
1146
|
+
const settingsCmd = `xcodebuild -showBuildSettings ${buildSource} -scheme "${scheme}" -sdk iphonesimulator -derivedDataPath "${derivedData}" 2>/dev/null`;
|
|
1147
|
+
const settings = await runShell(settingsCmd);
|
|
1148
|
+
const builtProductsDir = settings.match(/^\s*BUILT_PRODUCTS_DIR = (.+)/m)?.[1]?.trim();
|
|
1149
|
+
const productName = settings.match(/^\s*FULL_PRODUCT_NAME = (.+)/m)?.[1]?.trim();
|
|
1150
|
+
const appPath = builtProductsDir && productName ? `${builtProductsDir}/${productName}` : "";
|
|
1208
1151
|
if (!appPath) {
|
|
1209
1152
|
return {
|
|
1210
1153
|
content: [{ type: "text", text: log.join("\n") + "\nError: Could not find .app bundle" }],
|
|
@@ -1234,26 +1177,23 @@ async function handleBuildRun(command, params) {
|
|
|
1234
1177
|
catch {
|
|
1235
1178
|
// Not running
|
|
1236
1179
|
}
|
|
1237
|
-
// Clean up old port file
|
|
1238
|
-
try {
|
|
1239
|
-
await runShell(`rm -f "/tmp/nerve-ports/${udid}-${bundleId}.json"`);
|
|
1240
|
-
}
|
|
1241
|
-
catch { /* ignore */ }
|
|
1242
1180
|
// Launch
|
|
1243
1181
|
await runShell(`xcrun simctl launch "${udid}" "${bundleId}"`);
|
|
1244
1182
|
log.push("Launched.");
|
|
1245
|
-
//
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1183
|
+
// Set active target
|
|
1184
|
+
activeTarget = { udid, bundleId };
|
|
1185
|
+
const port = nervePort(udid, bundleId);
|
|
1186
|
+
// Wait for Nerve to be ready: TCP probe on calculated port
|
|
1187
|
+
let nerveReady = false;
|
|
1188
|
+
for (let i = 0; i < 40; i++) {
|
|
1189
|
+
if (await tcpProbe("127.0.0.1", port, 500)) {
|
|
1190
|
+
log.push(`Nerve ready on port ${port}`);
|
|
1191
|
+
nerveReady = true;
|
|
1252
1192
|
break;
|
|
1253
1193
|
}
|
|
1254
|
-
await new Promise(r => setTimeout(r,
|
|
1194
|
+
await new Promise(r => setTimeout(r, 250));
|
|
1255
1195
|
}
|
|
1256
|
-
if (!
|
|
1196
|
+
if (!nerveReady) {
|
|
1257
1197
|
log.push("Nerve did not start. Ensure your app includes the Nerve SPM package with Nerve.start() in #if DEBUG.");
|
|
1258
1198
|
}
|
|
1259
1199
|
return { content: [{ type: "text", text: log.join("\n") }] };
|
|
@@ -1267,6 +1207,181 @@ async function handleBuildRun(command, params) {
|
|
|
1267
1207
|
};
|
|
1268
1208
|
}
|
|
1269
1209
|
}
|
|
1210
|
+
// --- Appearance (Mac-side + in-app fallback) ---
|
|
1211
|
+
async function handleAppearance(params, targetId) {
|
|
1212
|
+
const mode = params.mode;
|
|
1213
|
+
if (!mode || !["dark", "light", "toggle"].includes(mode)) {
|
|
1214
|
+
return {
|
|
1215
|
+
content: [{ type: "text", text: "Error: 'mode' must be 'dark', 'light', or 'toggle'" }],
|
|
1216
|
+
isError: true,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
// Try simctl first (simulator)
|
|
1220
|
+
if (activeTarget) {
|
|
1221
|
+
try {
|
|
1222
|
+
if (mode === "toggle") {
|
|
1223
|
+
// Read current, then flip
|
|
1224
|
+
const current = (await runShell(`xcrun simctl ui "${activeTarget.udid}" appearance 2>/dev/null`)).trim();
|
|
1225
|
+
const newMode = current === "dark" ? "light" : "dark";
|
|
1226
|
+
await runShell(`xcrun simctl ui "${activeTarget.udid}" appearance ${newMode}`);
|
|
1227
|
+
return { content: [{ type: "text", text: `Switched to ${newMode} mode (was ${current})` }] };
|
|
1228
|
+
}
|
|
1229
|
+
await runShell(`xcrun simctl ui "${activeTarget.udid}" appearance ${mode}`);
|
|
1230
|
+
return { content: [{ type: "text", text: `Switched to ${mode} mode` }] };
|
|
1231
|
+
}
|
|
1232
|
+
catch {
|
|
1233
|
+
// Not a simulator — fall through to in-app override
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
// Fallback: override via Nerve in-app (works on device too)
|
|
1237
|
+
try {
|
|
1238
|
+
let style;
|
|
1239
|
+
if (mode === "toggle") {
|
|
1240
|
+
// Ask the app for current style, then flip
|
|
1241
|
+
const current = await send("modify", {
|
|
1242
|
+
query: ".UIWindow:0",
|
|
1243
|
+
property: "overrideUserInterfaceStyle",
|
|
1244
|
+
}, targetId);
|
|
1245
|
+
// 0 = unspecified, 1 = light, 2 = dark
|
|
1246
|
+
style = current.includes("2") ? "1" : "2";
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
style = mode === "dark" ? "2" : "1";
|
|
1250
|
+
}
|
|
1251
|
+
const result = await send("modify", {
|
|
1252
|
+
query: ".UIWindow:0",
|
|
1253
|
+
property: "overrideUserInterfaceStyle",
|
|
1254
|
+
value: parseInt(style),
|
|
1255
|
+
}, targetId);
|
|
1256
|
+
const label = style === "2" ? "dark" : "light";
|
|
1257
|
+
return { content: [{ type: "text", text: `Switched to ${label} mode (in-app override)\n${result}` }] };
|
|
1258
|
+
}
|
|
1259
|
+
catch (e) {
|
|
1260
|
+
return {
|
|
1261
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
1262
|
+
isError: true,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// --- Physical Device (Mac-side) ---
|
|
1267
|
+
async function handleListDevices() {
|
|
1268
|
+
try {
|
|
1269
|
+
const output = await runShell("xcrun devicectl list devices 2>&1");
|
|
1270
|
+
return { content: [{ type: "text", text: output.trim() }] };
|
|
1271
|
+
}
|
|
1272
|
+
catch (e) {
|
|
1273
|
+
return {
|
|
1274
|
+
content: [{ type: "text", text: `Error listing devices: ${e.message}` }],
|
|
1275
|
+
isError: true,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async function findDeviceIdentifier(nameOrUDID) {
|
|
1280
|
+
const output = await runShell("xcrun devicectl list devices -j 2>/dev/null");
|
|
1281
|
+
const data = JSON.parse(output);
|
|
1282
|
+
const devices = data?.result?.devices ?? [];
|
|
1283
|
+
for (const d of devices) {
|
|
1284
|
+
if (d.connectionProperties?.transportType !== "wired" && d.connectionProperties?.transportType !== "localNetwork")
|
|
1285
|
+
continue;
|
|
1286
|
+
const devName = d.deviceProperties?.name ?? "";
|
|
1287
|
+
const devId = d.hardwareProperties?.udid ?? d.identifier ?? "";
|
|
1288
|
+
const coreId = d.identifier ?? "";
|
|
1289
|
+
if (nameOrUDID) {
|
|
1290
|
+
if (devName === nameOrUDID || devId === nameOrUDID || coreId === nameOrUDID) {
|
|
1291
|
+
return { identifier: coreId, name: devName };
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
// Return first available paired device
|
|
1296
|
+
if (d.connectionProperties?.pairingState === "paired") {
|
|
1297
|
+
return { identifier: coreId, name: devName };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
throw new Error(nameOrUDID
|
|
1302
|
+
? `Device '${nameOrUDID}' not found or not connected. Run nerve_list_devices to see available devices.`
|
|
1303
|
+
: "No paired device found. Connect a device and run nerve_list_devices.");
|
|
1304
|
+
}
|
|
1305
|
+
async function handleRunDevice(params) {
|
|
1306
|
+
const scheme = params.scheme;
|
|
1307
|
+
const workspace = params.workspace;
|
|
1308
|
+
const project = params.project;
|
|
1309
|
+
const device = params.device;
|
|
1310
|
+
const team = params.team;
|
|
1311
|
+
if (!scheme) {
|
|
1312
|
+
return {
|
|
1313
|
+
content: [{ type: "text", text: "Error: 'scheme' parameter is required" }],
|
|
1314
|
+
isError: true,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
const log = [];
|
|
1318
|
+
try {
|
|
1319
|
+
// Find the device
|
|
1320
|
+
const dev = await findDeviceIdentifier(device);
|
|
1321
|
+
log.push(`Device: ${dev.name} (${dev.identifier})`);
|
|
1322
|
+
// Build for device
|
|
1323
|
+
let buildSource = "";
|
|
1324
|
+
if (workspace)
|
|
1325
|
+
buildSource = `-workspace "${workspace}"`;
|
|
1326
|
+
else if (project)
|
|
1327
|
+
buildSource = `-project "${project}"`;
|
|
1328
|
+
const projectDir = workspace ? path.dirname(path.resolve(workspace)) : project ? path.dirname(path.resolve(project)) : process.cwd();
|
|
1329
|
+
const projectName = path.basename(projectDir);
|
|
1330
|
+
const derivedData = `/tmp/nerve-derived-data-${projectName}`;
|
|
1331
|
+
let signingFlags = "";
|
|
1332
|
+
if (team) {
|
|
1333
|
+
signingFlags = `DEVELOPMENT_TEAM="${team}" CODE_SIGN_STYLE=Automatic`;
|
|
1334
|
+
}
|
|
1335
|
+
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`;
|
|
1336
|
+
log.push(`Building ${scheme} for device...`);
|
|
1337
|
+
let buildOutput;
|
|
1338
|
+
try {
|
|
1339
|
+
buildOutput = await runShell(buildCmd, 300000);
|
|
1340
|
+
}
|
|
1341
|
+
catch (e) {
|
|
1342
|
+
const errMsg = e.message;
|
|
1343
|
+
log.push(errMsg);
|
|
1344
|
+
return {
|
|
1345
|
+
content: [{ type: "text", text: log.join("\n") }],
|
|
1346
|
+
isError: true,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
if (buildOutput.trim())
|
|
1350
|
+
log.push(buildOutput.trim());
|
|
1351
|
+
log.push("Build succeeded.");
|
|
1352
|
+
// Find the .app bundle
|
|
1353
|
+
const settingsCmd = `xcodebuild -showBuildSettings ${buildSource} -scheme "${scheme}" -sdk iphoneos -derivedDataPath "${derivedData}" 2>/dev/null`;
|
|
1354
|
+
const settings = await runShell(settingsCmd);
|
|
1355
|
+
const builtProductsDir = settings.match(/^\s*BUILT_PRODUCTS_DIR = (.+)/m)?.[1]?.trim();
|
|
1356
|
+
const productName = settings.match(/^\s*FULL_PRODUCT_NAME = (.+)/m)?.[1]?.trim();
|
|
1357
|
+
const appPath = builtProductsDir && productName ? `${builtProductsDir}/${productName}` : "";
|
|
1358
|
+
if (!appPath) {
|
|
1359
|
+
return {
|
|
1360
|
+
content: [{ type: "text", text: log.join("\n") + "\nError: Could not find .app bundle" }],
|
|
1361
|
+
isError: true,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
const bundleId = (await runShell(`/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`)).trim();
|
|
1365
|
+
log.push(`App: ${bundleId}`);
|
|
1366
|
+
// Install on device
|
|
1367
|
+
log.push("Installing on device...");
|
|
1368
|
+
await runShell(`xcrun devicectl device install app --device "${dev.identifier}" "${appPath}" 2>&1`, 120000);
|
|
1369
|
+
log.push("Installed.");
|
|
1370
|
+
// Launch on device
|
|
1371
|
+
log.push("Launching...");
|
|
1372
|
+
await runShell(`xcrun devicectl device process launch --device "${dev.identifier}" "${bundleId}" 2>&1`, 30000);
|
|
1373
|
+
log.push("Launched.");
|
|
1374
|
+
return { content: [{ type: "text", text: log.join("\n") }] };
|
|
1375
|
+
}
|
|
1376
|
+
catch (e) {
|
|
1377
|
+
const error = e;
|
|
1378
|
+
log.push(`Error: ${error.message}`);
|
|
1379
|
+
return {
|
|
1380
|
+
content: [{ type: "text", text: log.join("\n") }],
|
|
1381
|
+
isError: true,
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1270
1385
|
// --- Grant Permissions (Mac-side) ---
|
|
1271
1386
|
async function handleGrantPermissions(params) {
|
|
1272
1387
|
const services = params.services;
|
|
@@ -1276,32 +1391,13 @@ async function handleGrantPermissions(params) {
|
|
|
1276
1391
|
isError: true,
|
|
1277
1392
|
};
|
|
1278
1393
|
}
|
|
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) {
|
|
1394
|
+
if (!activeTarget) {
|
|
1300
1395
|
return {
|
|
1301
|
-
content: [{ type: "text", text: "Error: No
|
|
1396
|
+
content: [{ type: "text", text: "Error: No active target. Launch the app first with nerve_run." }],
|
|
1302
1397
|
isError: true,
|
|
1303
1398
|
};
|
|
1304
1399
|
}
|
|
1400
|
+
const { udid, bundleId } = activeTarget;
|
|
1305
1401
|
const log = [];
|
|
1306
1402
|
for (const service of services) {
|
|
1307
1403
|
try {
|
|
@@ -1323,22 +1419,7 @@ async function handleDeeplinkSimctl(params) {
|
|
|
1323
1419
|
isError: true,
|
|
1324
1420
|
};
|
|
1325
1421
|
}
|
|
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
|
-
}
|
|
1422
|
+
let udid = activeTarget?.udid;
|
|
1342
1423
|
if (!udid) {
|
|
1343
1424
|
// Try booted simulators
|
|
1344
1425
|
try {
|
|
@@ -1375,14 +1456,9 @@ async function handleDeeplinkSimctl(params) {
|
|
|
1375
1456
|
}
|
|
1376
1457
|
// --- Main ---
|
|
1377
1458
|
async function main() {
|
|
1378
|
-
// Initial discovery
|
|
1379
|
-
await connection.discover();
|
|
1380
|
-
// Start MCP server on stdio
|
|
1381
1459
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
1382
1460
|
await server.connect(transport);
|
|
1383
1461
|
console.error("[nerve] MCP server started");
|
|
1384
|
-
// Periodic re-discovery
|
|
1385
|
-
setInterval(() => connection.discover(), 3000);
|
|
1386
1462
|
}
|
|
1387
1463
|
main().catch((e) => {
|
|
1388
1464
|
console.error("[nerve] Fatal:", e);
|