nerve-mcp 0.1.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 ADDED
@@ -0,0 +1,1390 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
41
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
42
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
43
+ const ws_1 = __importDefault(require("ws"));
44
+ const child_process_1 = require("child_process");
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ // --- Discovery ---
48
+ function discoverSimulatorTargets() {
49
+ const dir = "/tmp/nerve-ports";
50
+ if (!fs.existsSync(dir))
51
+ return [];
52
+ const targets = [];
53
+ for (const file of fs.readdirSync(dir)) {
54
+ if (!file.endsWith(".json"))
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
+ }
81
+ }
82
+ return targets;
83
+ }
84
+ function discoverBonjourTargets() {
85
+ // Use dns-sd to browse for _nerve._tcp services (non-blocking check)
86
+ try {
87
+ // Quick one-shot browse with timeout
88
+ const result = (0, child_process_1.execSync)('dns-sd -B _nerve._tcp . 2>/dev/null & PID=$!; sleep 1; kill $PID 2>/dev/null; wait $PID 2>/dev/null', { timeout: 3000, encoding: "utf-8" });
89
+ const targets = [];
90
+ for (const line of result.split("\n")) {
91
+ const match = line.match(/Nerve-(\S+)/);
92
+ if (match) {
93
+ // Resolve would need another dns-sd call. For MVP, skip and rely on
94
+ // manual connection or iproxy.
95
+ }
96
+ }
97
+ return targets;
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ }
103
+ // --- WebSocket Connection ---
104
+ class NerveConnection {
105
+ targets = new Map();
106
+ pendingRequests = new Map();
107
+ requestCounter = 0;
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}`;
143
+ const ws = new ws_1.default(url);
144
+ let pingInterval;
145
+ ws.on("open", () => {
146
+ target.ws = ws;
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);
163
+ });
164
+ ws.on("message", (data) => {
165
+ try {
166
+ const response = JSON.parse(data.toString());
167
+ const pending = this.pendingRequests.get(response.id);
168
+ if (pending) {
169
+ clearTimeout(pending.timer);
170
+ this.pendingRequests.delete(response.id);
171
+ if (response.ok) {
172
+ pending.resolve(response.data);
173
+ }
174
+ else {
175
+ pending.reject(new Error(response.data));
176
+ }
177
+ }
178
+ }
179
+ catch {
180
+ // Ignore malformed messages
181
+ }
182
+ });
183
+ ws.on("close", () => {
184
+ if (pingInterval)
185
+ clearInterval(pingInterval);
186
+ target.connected = false;
187
+ target.ws = undefined;
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
+ }
237
+ }
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
+ });
288
+ }
289
+ }
290
+ // --- MCP Server ---
291
+ const connection = new NerveConnection();
292
+ const server = new index_js_1.Server({ name: "nerve", version: "0.1.0" }, {
293
+ capabilities: { tools: {} },
294
+ 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.
295
+
296
+ ## Autonomous Verification Loop
297
+
298
+ When developing features or fixing bugs, use this closed loop to verify your changes without human intervention:
299
+
300
+ 1. **Write code** — implement the feature or fix. Add \`print("[nerve] ...")\` trace logs at key points (e.g., \`print("[nerve] item added: \\(item.name)")\`).
301
+ 2. **Build & launch** — call nerve_run to build and launch the app on the simulator.
302
+ 3. **Navigate** — use nerve_view to see the screen, then nerve_tap to navigate to the relevant screen.
303
+ 4. **Perform the action** — tap buttons, fill fields, trigger the feature you're testing.
304
+ 5. **Read results** — call nerve_console with filter="[nerve]" and since="last_action" to read your trace logs. Call nerve_view to see the screen state.
305
+ 6. **Iterate** — if the output is wrong, fix the code and go back to step 2. Repeat until correct.
306
+
307
+ This replaces the manual cycle of: edit code → build in Xcode → manually tap through the app → copy logs → paste back.
308
+
309
+ ## How to navigate and interact
310
+
311
+ ### See the screen
312
+ Call nerve_view to see all visible elements with their type (btn, txt, field, toggle), label, identifier (#id), tap point, and position. Always call this before interacting.
313
+
314
+ Each element has a ref like @e1, @e2 — use these directly: nerve_tap "@e2".
315
+ Elements with identifiers can also be tapped by #id: nerve_tap "#login-btn".
316
+ The tap= coordinate is the center point where the element is reliably hittable.
317
+
318
+ ### Navigate
319
+ - nerve_tap to tap tabs, buttons, links, and rows to move between screens.
320
+ - nerve_back to go back, nerve_dismiss to close modals/keyboard.
321
+ - nerve_map to see all discovered screens. nerve_navigate to auto-navigate to a known screen.
322
+ - nerve_deeplink to open a URL scheme directly.
323
+ - After navigation, call nerve_view to see the new screen.
324
+
325
+ ### Interact
326
+ - nerve_tap to press buttons and select items. Use @eN refs or #id.
327
+ - nerve_type to enter text (tap the field first to focus it).
328
+ - nerve_scroll or nerve_scroll_to_find for off-screen content.
329
+ - **No sleep needed between commands.** Every interaction command automatically waits for the UI to settle (animations complete, transitions finish) before returning. Just send commands back-to-back.
330
+
331
+ ### Wait for async work
332
+ - nerve_wait_idle to wait for network requests + animations to finish.
333
+ - nerve_network to check specific requests.
334
+ - Note: auto-wait after actions only covers UI settling (animations, transitions). For network completion, use nerve_wait_idle or nerve_network explicitly.
335
+
336
+ ### Verify
337
+ - nerve_view to see updated screen state.
338
+ - nerve_console with filter="[nerve]" and since="last_action" for your trace logs.
339
+ - nerve_screenshot for visual confirmation.
340
+ - nerve_heap to inspect live objects (e.g., check ViewModel state).
341
+
342
+ ### Tips
343
+ - Always call nerve_view before interacting — don't guess element identifiers.
344
+ - Use @eN refs from nerve_view output to tap elements without identifiers.
345
+ - If an element isn't visible, try nerve_scroll_to_find before giving up.
346
+ - The navigation map builds automatically and persists across sessions.
347
+ - Do NOT add sleep/delay between commands — Nerve handles waiting automatically.
348
+ - Call nerve_grant_permissions before features that need camera, photos, location, etc.`,
349
+ });
350
+ // Tool definitions
351
+ const TOOLS = [
352
+ {
353
+ name: "nerve_view",
354
+ description: "See the current screen. Returns all visible UI elements with their type (btn, txt, field, toggle), label, identifier (#id), and position. This is your primary tool for understanding what's on screen. Always call this before interacting with elements.",
355
+ inputSchema: {
356
+ type: "object",
357
+ properties: {
358
+ target: { type: "string", description: "Target ID. Auto-selects if only one connected." },
359
+ },
360
+ },
361
+ },
362
+ {
363
+ name: "nerve_tree",
364
+ description: "Dump the complete view hierarchy tree showing all views with nesting, types, and frames.",
365
+ inputSchema: {
366
+ type: "object",
367
+ properties: {
368
+ target: { type: "string" },
369
+ depth: { type: "number", description: "Max depth. Default: unlimited." },
370
+ },
371
+ },
372
+ },
373
+ {
374
+ name: "nerve_inspect",
375
+ description: "Inspect a specific UI element. Returns properties, accessibility info, constraints, and type information. Query by #identifier, @label, .Type, or x,y coordinates.",
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ target: { type: "string" },
380
+ query: { type: "string", description: "Element query: #id, @label, .Type:index, or x,y" },
381
+ },
382
+ required: ["query"],
383
+ },
384
+ },
385
+ {
386
+ name: "nerve_tap",
387
+ description: "Tap a UI element to press buttons, select items, navigate, or focus text fields. Use #identifier (most reliable), @label (by visible text), or x,y coordinates.",
388
+ inputSchema: {
389
+ type: "object",
390
+ properties: {
391
+ target: { type: "string" },
392
+ query: { type: "string", description: "Element to tap: #id, @label, .Type, or x,y" },
393
+ },
394
+ required: ["query"],
395
+ },
396
+ },
397
+ {
398
+ name: "nerve_scroll",
399
+ description: "Scroll the current scroll view in a direction.",
400
+ inputSchema: {
401
+ type: "object",
402
+ properties: {
403
+ target: { type: "string" },
404
+ direction: { type: "string", enum: ["up", "down", "left", "right"] },
405
+ amount: { type: "number", description: "Scroll distance in points. Default: 300." },
406
+ },
407
+ required: ["direction"],
408
+ },
409
+ },
410
+ {
411
+ name: "nerve_swipe",
412
+ description: "Perform a swipe gesture in a direction.",
413
+ inputSchema: {
414
+ type: "object",
415
+ properties: {
416
+ target: { type: "string" },
417
+ direction: { type: "string", enum: ["up", "down", "left", "right"] },
418
+ from: { type: "string", description: "Starting point as 'x,y'. Default: screen center." },
419
+ },
420
+ required: ["direction"],
421
+ },
422
+ },
423
+ {
424
+ name: "nerve_double_tap",
425
+ description: "Double-tap a UI element. Used for zoom in/out on maps, text selection.",
426
+ inputSchema: {
427
+ type: "object",
428
+ properties: {
429
+ target: { type: "string" },
430
+ query: { type: "string", description: "Element to double-tap: #id, @label, .Type, or x,y" },
431
+ },
432
+ required: ["query"],
433
+ },
434
+ },
435
+ {
436
+ name: "nerve_drag_drop",
437
+ description: "Drag and drop: long-press the source element, drag to the target, release.",
438
+ inputSchema: {
439
+ type: "object",
440
+ properties: {
441
+ target: { type: "string" },
442
+ from: { type: "string", description: "Source element: #id, @label, or x,y" },
443
+ to: { type: "string", description: "Destination element: #id, @label, or x,y" },
444
+ hold_duration: { type: "number", description: "Hold time before dragging (seconds). Default: 0.5." },
445
+ drag_duration: { type: "number", description: "Drag animation time (seconds). Default: 0.5." },
446
+ },
447
+ required: ["from", "to"],
448
+ },
449
+ },
450
+ {
451
+ name: "nerve_pull_to_refresh",
452
+ description: "Pull down to refresh the current scroll view content.",
453
+ inputSchema: {
454
+ type: "object",
455
+ properties: {
456
+ target: { type: "string" },
457
+ },
458
+ },
459
+ },
460
+ {
461
+ name: "nerve_pinch",
462
+ description: "Two-finger pinch/zoom gesture. Scale > 1.0 zooms in, < 1.0 zooms out.",
463
+ inputSchema: {
464
+ type: "object",
465
+ properties: {
466
+ target: { type: "string" },
467
+ query: { type: "string", description: "Element to pinch on: #id, @label, or x,y. Default: screen center." },
468
+ scale: { type: "number", description: "Zoom scale factor. Default: 2.0 (zoom in). Use 0.5 for zoom out." },
469
+ },
470
+ },
471
+ },
472
+ {
473
+ name: "nerve_context_menu",
474
+ description: "Open a context menu by long-pressing an element. Returns the current screen showing menu items. Use nerve_tap to select a menu item.",
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ target: { type: "string" },
479
+ query: { type: "string", description: "Element to long-press for context menu: #id, @label, or x,y" },
480
+ },
481
+ required: ["query"],
482
+ },
483
+ },
484
+ {
485
+ name: "nerve_long_press",
486
+ description: "Long-press a UI element. Triggers context menus, drag initiation, and haptic touch actions.",
487
+ inputSchema: {
488
+ type: "object",
489
+ properties: {
490
+ target: { type: "string" },
491
+ query: { type: "string", description: "Element to long-press: #id, @label, .Type, or x,y" },
492
+ duration: { type: "number", description: "Press duration in seconds. Default: 1.0." },
493
+ },
494
+ required: ["query"],
495
+ },
496
+ },
497
+ {
498
+ name: "nerve_type",
499
+ description: "Type text into the currently focused text field. Tap the field first with nerve_tap to focus it, then call this to enter text.",
500
+ inputSchema: {
501
+ type: "object",
502
+ properties: {
503
+ target: { type: "string" },
504
+ text: { type: "string", description: "Text to type." },
505
+ submit: { type: "boolean", description: "Press Return after typing. Default: false." },
506
+ },
507
+ required: ["text"],
508
+ },
509
+ },
510
+ {
511
+ name: "nerve_back",
512
+ description: "Navigate back (pop navigation or dismiss presented view controller).",
513
+ inputSchema: {
514
+ type: "object",
515
+ properties: {
516
+ target: { type: "string" },
517
+ },
518
+ },
519
+ },
520
+ {
521
+ name: "nerve_dismiss",
522
+ description: "Dismiss the keyboard or the frontmost presented view controller.",
523
+ inputSchema: {
524
+ type: "object",
525
+ properties: {
526
+ target: { type: "string" },
527
+ },
528
+ },
529
+ },
530
+ {
531
+ name: "nerve_screenshot",
532
+ description: "Capture a screenshot of the current screen. Returns base64-encoded PNG.",
533
+ inputSchema: {
534
+ type: "object",
535
+ properties: {
536
+ target: { type: "string" },
537
+ scale: { type: "number", description: "Image scale. Default: 1.0." },
538
+ },
539
+ },
540
+ },
541
+ {
542
+ name: "nerve_console",
543
+ description: "Read the app's console log output. Use filter to narrow results (e.g., filter='[nerve]' for your trace logs, filter='error' for errors). Use since='last_action' to see only logs from the most recent interaction.",
544
+ inputSchema: {
545
+ type: "object",
546
+ properties: {
547
+ target: { type: "string" },
548
+ limit: { type: "number", description: "Max log lines. Default: 50." },
549
+ filter: { type: "string", description: "Keyword filter (e.g., '[nerve]' to see only your trace logs)." },
550
+ level: { type: "string", enum: ["debug", "info", "warning", "error"] },
551
+ since: { type: "string", enum: ["last_action"], description: "Only show logs since the last tap/scroll/type/swipe action." },
552
+ },
553
+ },
554
+ },
555
+ {
556
+ name: "nerve_network",
557
+ description: "Show recent HTTP requests with method, URL, status, and timing. In-flight requests show as 'pending'. Use index to see full response body and headers for a specific request — e.g., check what the API returned after a login call.",
558
+ inputSchema: {
559
+ type: "object",
560
+ properties: {
561
+ target: { type: "string" },
562
+ limit: { type: "number", description: "Max transactions. Default: 20." },
563
+ filter: { type: "string", description: "URL pattern filter." },
564
+ index: { type: "number", description: "Transaction number (from the list) to see full response body and headers." },
565
+ },
566
+ },
567
+ },
568
+ {
569
+ name: "nerve_heap",
570
+ description: "Find live instances of a class on the heap and inspect their properties. First call with just class_name to list instances. Then call with index to read all properties of a specific instance (e.g., check a ViewModel's state).",
571
+ inputSchema: {
572
+ type: "object",
573
+ properties: {
574
+ target: { type: "string" },
575
+ class_name: { type: "string", description: "Class name (e.g., 'UserViewModel', 'UINavigationController')." },
576
+ limit: { type: "number", description: "Max instances. Default: 20." },
577
+ index: { type: "number", description: "Instance number (1-based) to inspect all properties." },
578
+ },
579
+ required: ["class_name"],
580
+ },
581
+ },
582
+ {
583
+ name: "nerve_storage",
584
+ description: "Read app storage: UserDefaults, Keychain, cookies, sandbox files, or Core Data. For Core Data, omit entity to list all entities, or specify entity to fetch records.",
585
+ inputSchema: {
586
+ type: "object",
587
+ properties: {
588
+ target: { type: "string" },
589
+ type: { type: "string", enum: ["defaults", "keychain", "cookies", "files", "coredata"] },
590
+ key: { type: "string", description: "Specific key (for defaults)." },
591
+ path: { type: "string", description: "Directory path (for files)." },
592
+ entity: { type: "string", description: "Core Data entity name. Omit to list all entities." },
593
+ predicate: { type: "string", description: "NSPredicate to filter Core Data records (e.g., \"name CONTAINS 'milk'\")." },
594
+ limit: { type: "number", description: "Max records for Core Data. Default: 20." },
595
+ },
596
+ required: ["type"],
597
+ },
598
+ },
599
+ {
600
+ name: "nerve_status",
601
+ description: "Check connection status, app state, and Nerve version for all connected targets.",
602
+ inputSchema: {
603
+ type: "object",
604
+ properties: {},
605
+ },
606
+ },
607
+ {
608
+ name: "nerve_map",
609
+ description: "Show the app's navigation map — all discovered screens and how to get between them. The map builds automatically as you navigate and persists across sessions. Use this to plan how to reach a specific screen. Use format='json' to export.",
610
+ inputSchema: {
611
+ type: "object",
612
+ properties: {
613
+ target: { type: "string" },
614
+ format: { type: "string", enum: ["text", "json"], description: "Output format. Default: text." },
615
+ import: { type: "string", description: "JSON string to import a previously exported map." },
616
+ },
617
+ },
618
+ },
619
+ {
620
+ name: "nerve_navigate",
621
+ description: "Auto-navigate to a screen by name (e.g., 'Settings', 'Orders'). Uses the navigation map to find the shortest path and executes each step automatically. Call nerve_map first to see available screens. If the screen isn't in the map yet, navigate there manually with nerve_tap to discover it.",
622
+ inputSchema: {
623
+ type: "object",
624
+ properties: {
625
+ target: { type: "string" },
626
+ target_screen: { type: "string", description: "Screen name to navigate to (e.g., 'SettingsViewController', 'CheckoutScreen')." },
627
+ inputs: {
628
+ type: "object",
629
+ description: "Map of field identifiers to values for screens requiring input (e.g., {\"#email-field\": \"test@example.com\", \"#password-field\": \"pass123\"}).",
630
+ additionalProperties: { type: "string" },
631
+ },
632
+ },
633
+ required: ["target_screen"],
634
+ },
635
+ },
636
+ {
637
+ name: "nerve_action",
638
+ description: "Invoke a custom accessibility action on an element. Use nerve_inspect to discover available actions first.",
639
+ inputSchema: {
640
+ type: "object",
641
+ properties: {
642
+ target: { type: "string" },
643
+ query: { type: "string", description: "Element query: #id, @label, .Type, or x,y" },
644
+ action: { type: "string", description: "Name of the custom accessibility action to invoke." },
645
+ },
646
+ required: ["query", "action"],
647
+ },
648
+ },
649
+ {
650
+ name: "nerve_scroll_to_find",
651
+ description: "Find an element that's off-screen by scrolling through the list. Use this when nerve_view doesn't show the element you need — it may be below the fold in a scrollable list.",
652
+ inputSchema: {
653
+ type: "object",
654
+ properties: {
655
+ target: { type: "string" },
656
+ query: { type: "string", description: "Element to find: #id, @label" },
657
+ max_attempts: { type: "number", description: "Max scroll pages to try. Default: 10." },
658
+ },
659
+ required: ["query"],
660
+ },
661
+ },
662
+ {
663
+ name: "nerve_wait_idle",
664
+ description: "Wait until ALL network requests complete and animations finish. Simple but may wait too long if the app has background polling. For precise control, use nerve_network instead to check if a specific request completed.",
665
+ inputSchema: {
666
+ type: "object",
667
+ properties: {
668
+ target: { type: "string" },
669
+ timeout: { type: "number", description: "Max wait time in seconds. Default: 5." },
670
+ quiet: { type: "number", description: "Seconds of no activity before considered idle. Default: 1." },
671
+ },
672
+ },
673
+ },
674
+ {
675
+ name: "nerve_build",
676
+ description: "Build an iOS app for the simulator using xcodebuild.",
677
+ inputSchema: {
678
+ type: "object",
679
+ properties: {
680
+ scheme: { type: "string", description: "Xcode scheme to build." },
681
+ workspace: { type: "string", description: "Path to .xcworkspace (optional)." },
682
+ project: { type: "string", description: "Path to .xcodeproj (optional)." },
683
+ simulator: { type: "string", description: "Simulator name. Default: iPhone 16 Pro." },
684
+ },
685
+ required: ["scheme"],
686
+ },
687
+ },
688
+ {
689
+ name: "nerve_run",
690
+ description: "Build, install, and launch an iOS app on the simulator. The app must include the Nerve SPM package. After launching, call nerve_view to see the initial screen, then navigate and interact as needed.",
691
+ inputSchema: {
692
+ type: "object",
693
+ properties: {
694
+ scheme: { type: "string", description: "Xcode scheme to build." },
695
+ workspace: { type: "string", description: "Path to .xcworkspace (optional)." },
696
+ project: { type: "string", description: "Path to .xcodeproj (optional)." },
697
+ simulator: { type: "string", description: "Simulator name. Default: iPhone 16 Pro." },
698
+ },
699
+ required: ["scheme"],
700
+ },
701
+ },
702
+ {
703
+ name: "nerve_deeplink",
704
+ description: "Open a deeplink URL in the app to navigate directly to a screen. Works with custom URL schemes (e.g., 'myapp://settings') and universal links.",
705
+ inputSchema: {
706
+ type: "object",
707
+ properties: {
708
+ target: { type: "string" },
709
+ url: { type: "string", description: "The deeplink URL to open (e.g., 'myapp://settings/profile')." },
710
+ method: { type: "string", enum: ["in_app", "simctl"], description: "How to open: 'in_app' uses UIApplication.open (default), 'simctl' uses xcrun simctl openurl." },
711
+ },
712
+ required: ["url"],
713
+ },
714
+ },
715
+ {
716
+ name: "nerve_grant_permissions",
717
+ description: "Pre-grant iOS permissions so system dialogs never appear during automation. Call before interacting with features that require permissions (camera, location, photos, etc.).",
718
+ inputSchema: {
719
+ type: "object",
720
+ properties: {
721
+ services: {
722
+ type: "array",
723
+ items: { type: "string" },
724
+ description: "Permissions to grant: 'all', 'camera', 'photos', 'location', 'location-always', 'contacts', 'microphone', 'calendar', 'reminders', 'motion', 'tracking', 'speech-recognition'.",
725
+ },
726
+ },
727
+ required: ["services"],
728
+ },
729
+ },
730
+ {
731
+ name: "nerve_list_simulators",
732
+ description: "List available iOS simulators and their state (Booted/Shutdown).",
733
+ inputSchema: {
734
+ type: "object",
735
+ properties: {
736
+ booted_only: { type: "boolean", description: "Only show booted simulators. Default: false." },
737
+ },
738
+ required: [],
739
+ },
740
+ },
741
+ {
742
+ name: "nerve_boot_simulator",
743
+ description: "Boot a simulator by name or UDID. Opens the Simulator app if not already open.",
744
+ inputSchema: {
745
+ type: "object",
746
+ properties: {
747
+ simulator: { type: "string", description: "Simulator name (e.g., 'iPhone 16 Pro') or UDID." },
748
+ },
749
+ required: ["simulator"],
750
+ },
751
+ },
752
+ {
753
+ name: "nerve_trace",
754
+ 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.",
755
+ inputSchema: {
756
+ type: "object",
757
+ properties: {
758
+ target: { type: "string" },
759
+ action: { type: "string", enum: ["add", "remove", "remove_all", "list"], description: "Action to perform. Default: add." },
760
+ class_name: { type: "string", description: "ObjC class name (e.g., 'UIViewController', 'LoginViewController')." },
761
+ method: { type: "string", description: "Selector name (e.g., 'viewDidAppear:', 'loginWithEmail:password:')." },
762
+ type: { type: "string", enum: ["instance", "class"], description: "Instance method (-) or class method (+). Default: instance." },
763
+ },
764
+ required: [],
765
+ },
766
+ },
767
+ {
768
+ name: "nerve_highlight",
769
+ description: "Draw a colored border around a UI element for visual debugging. Use with nerve_screenshot to see the result. Call with action='clear' to remove all highlights.",
770
+ inputSchema: {
771
+ type: "object",
772
+ properties: {
773
+ target: { type: "string" },
774
+ query: { type: "string", description: "Element query (#id, @label, or coordinates)." },
775
+ color: { type: "string", description: "Border color: red, blue, green, yellow, orange, purple, pink, cyan. Default: red." },
776
+ action: { type: "string", enum: ["show", "clear"], description: "Show highlight or clear all. Default: show." },
777
+ },
778
+ required: [],
779
+ },
780
+ },
781
+ {
782
+ name: "nerve_modify",
783
+ description: "Modify a UI element's properties at runtime without rebuilding. Test UI fixes instantly — change text, visibility, colors, or any KVC property.",
784
+ inputSchema: {
785
+ type: "object",
786
+ properties: {
787
+ target: { type: "string" },
788
+ query: { type: "string", description: "Element query (#id, @label, or coordinates)." },
789
+ hidden: { type: "string", description: "Set hidden state ('true' or 'false')." },
790
+ alpha: { type: "string", description: "Set opacity (0.0 to 1.0)." },
791
+ backgroundColor: { type: "string", description: "Set background color (red, blue, green, yellow, etc.)." },
792
+ text: { type: "string", description: "Set text content (works on labels, text fields, buttons)." },
793
+ enabled: { type: "string", description: "Set enabled state ('true' or 'false')." },
794
+ key: { type: "string", description: "KVC key for arbitrary property." },
795
+ value: { type: "string", description: "Value to set for the KVC key." },
796
+ },
797
+ required: ["query"],
798
+ },
799
+ },
800
+ {
801
+ name: "nerve_lldb",
802
+ description: "Execute an LLDB debugger command against the running app. The debugger session persists across calls. Use this for deep debugging: inspect variables, set breakpoints, evaluate expressions, view backtraces, and modify state at runtime.\n\nCommon commands:\n po <expr> — Print object description\n expr <code> — Evaluate expression (e.g., expr self.title = @\"new\")\n bt — Show backtrace\n frame variable — Show local variables\n breakpoint set -n <method> --auto-continue -C 'po self' — Log when method is called\n breakpoint set -f File.swift -l 42 — Break at line\n breakpoint list — List breakpoints\n breakpoint delete <id> — Remove breakpoint\n continue — Resume execution (after hitting breakpoint)\n thread list — Show all threads\n image lookup -n <symbol> — Find where a symbol is defined\n\nNote: When a real breakpoint is hit, the app freezes (including Nerve). Use --auto-continue for non-blocking logpoints. Use 'continue' to resume after a real breakpoint.",
803
+ inputSchema: {
804
+ type: "object",
805
+ properties: {
806
+ command: { type: "string", description: "LLDB command to execute (e.g., 'po [UIApplication sharedApplication]')" },
807
+ detach: { type: "boolean", description: "Detach the debugger and end the session." },
808
+ },
809
+ required: [],
810
+ },
811
+ },
812
+ ];
813
+ // --- LLDB Session (Mac-side) ---
814
+ class LLDBSession {
815
+ process = null;
816
+ pid = null;
817
+ output = "";
818
+ ready = false;
819
+ static SENTINEL = "__NERVE_LLDB_DONE__";
820
+ isAttached() {
821
+ return this.process !== null && this.ready;
822
+ }
823
+ async attach(pid) {
824
+ if (this.process && this.pid === pid) {
825
+ return "Already attached.";
826
+ }
827
+ if (this.process) {
828
+ this.detach();
829
+ }
830
+ this.pid = pid;
831
+ return new Promise((resolve, reject) => {
832
+ this.process = (0, child_process_1.spawn)("lldb", ["-p", String(pid)], {
833
+ stdio: ["pipe", "pipe", "pipe"],
834
+ });
835
+ const timeout = setTimeout(() => {
836
+ reject(new Error("LLDB attach timed out after 15s"));
837
+ }, 15000);
838
+ const onData = (data) => {
839
+ this.output += data.toString();
840
+ if (this.output.includes("(lldb)")) {
841
+ clearTimeout(timeout);
842
+ this.ready = true;
843
+ resolve(`Attached to PID ${pid}`);
844
+ }
845
+ };
846
+ this.process.stdout.on("data", onData);
847
+ this.process.stderr.on("data", onData);
848
+ this.process.on("close", () => {
849
+ this.process = null;
850
+ this.ready = false;
851
+ this.pid = null;
852
+ });
853
+ this.process.on("error", (err) => {
854
+ clearTimeout(timeout);
855
+ this.process = null;
856
+ this.ready = false;
857
+ reject(err);
858
+ });
859
+ });
860
+ }
861
+ async execute(command) {
862
+ if (!this.process || !this.ready) {
863
+ throw new Error("LLDB not attached.");
864
+ }
865
+ // Reset output buffer
866
+ this.output = "";
867
+ return new Promise((resolve) => {
868
+ const timeout = setTimeout(() => {
869
+ // On timeout, return whatever we have — app may be paused at breakpoint
870
+ const result = this.extractOutput();
871
+ resolve(result || "(no output — app may be paused at a breakpoint. Use 'continue' to resume)");
872
+ }, 15000);
873
+ const check = setInterval(() => {
874
+ if (this.output.includes(LLDBSession.SENTINEL)) {
875
+ clearTimeout(timeout);
876
+ clearInterval(check);
877
+ resolve(this.extractOutput());
878
+ }
879
+ }, 50);
880
+ // Send the actual command, then a sentinel so we know when output is complete
881
+ this.process.stdin.write(command + "\n");
882
+ this.process.stdin.write(`script print("${LLDBSession.SENTINEL}")\n`);
883
+ });
884
+ }
885
+ extractOutput() {
886
+ const lines = this.output.split("\n");
887
+ return lines
888
+ .filter(l => !l.includes(LLDBSession.SENTINEL) &&
889
+ !l.includes("script print") &&
890
+ !l.trimStart().startsWith("(lldb)") &&
891
+ !l.includes("stop reason = signal SIGSTOP") &&
892
+ !l.includes("Executable binary set to") &&
893
+ !l.includes("Architecture set to:"))
894
+ .join("\n")
895
+ .trim();
896
+ }
897
+ detach() {
898
+ if (this.process) {
899
+ try {
900
+ this.process.stdin.write("detach\n");
901
+ this.process.stdin.write("quit\n");
902
+ }
903
+ catch { }
904
+ setTimeout(() => {
905
+ try {
906
+ this.process?.kill();
907
+ }
908
+ catch { }
909
+ }, 1000);
910
+ this.process = null;
911
+ }
912
+ this.ready = false;
913
+ this.pid = null;
914
+ this.output = "";
915
+ }
916
+ }
917
+ const lldbSession = new LLDBSession();
918
+ async function handleLLDB(params) {
919
+ const command = params.command;
920
+ const detach = params.detach;
921
+ if (detach) {
922
+ lldbSession.detach();
923
+ return { content: [{ type: "text", text: "LLDB session detached." }] };
924
+ }
925
+ if (!command) {
926
+ return {
927
+ content: [{ type: "text", text: "Error: 'command' parameter is required" }],
928
+ isError: true,
929
+ };
930
+ }
931
+ // Auto-attach if not already connected
932
+ if (!lldbSession.isAttached()) {
933
+ // Find PID from Nerve port files
934
+ let pid;
935
+ const targets = connection.getConnectedTargets();
936
+ if (targets.length > 0 && targets[0].udid) {
937
+ const portFile = `/tmp/nerve-ports/${targets[0].udid}-${targets[0].bundleId}.json`;
938
+ if (fs.existsSync(portFile)) {
939
+ const info = JSON.parse(fs.readFileSync(portFile, "utf-8"));
940
+ pid = info.pid;
941
+ }
942
+ }
943
+ if (!pid) {
944
+ // Try any port file
945
+ const dir = "/tmp/nerve-ports";
946
+ if (fs.existsSync(dir)) {
947
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
948
+ for (const file of files) {
949
+ try {
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
+ }
958
+ }
959
+ if (!pid) {
960
+ return {
961
+ content: [{ type: "text", text: "Error: No running app found. Launch the app first with nerve_run." }],
962
+ isError: true,
963
+ };
964
+ }
965
+ try {
966
+ const attachMsg = await lldbSession.attach(pid);
967
+ const result = await lldbSession.execute(command);
968
+ return { content: [{ type: "text", text: `${attachMsg}\n\n${result}` }] };
969
+ }
970
+ catch (e) {
971
+ return {
972
+ content: [{ type: "text", text: `Error attaching LLDB: ${e.message}` }],
973
+ isError: true,
974
+ };
975
+ }
976
+ }
977
+ try {
978
+ const result = await lldbSession.execute(command);
979
+ return { content: [{ type: "text", text: result }] };
980
+ }
981
+ catch (e) {
982
+ return {
983
+ content: [{ type: "text", text: `LLDB error: ${e.message}` }],
984
+ isError: true,
985
+ };
986
+ }
987
+ }
988
+ // --- Simulator Management (Mac-side) ---
989
+ async function handleListSimulators(params) {
990
+ const bootedOnly = params.booted_only;
991
+ try {
992
+ const json = JSON.parse(await runShell("xcrun simctl list devices available -j"));
993
+ const lines = [];
994
+ for (const [runtime, devices] of Object.entries(json.devices)) {
995
+ // Extract OS version from runtime string
996
+ const osMatch = runtime.match(/iOS[- ](\d+[\d.-]*)/);
997
+ if (!osMatch)
998
+ continue;
999
+ const os = `iOS ${osMatch[1].replace(/-/g, ".")}`;
1000
+ const filtered = bootedOnly ? devices.filter((d) => d.state === "Booted") : devices;
1001
+ if (filtered.length === 0)
1002
+ continue;
1003
+ lines.push(`${os}:`);
1004
+ for (const d of filtered) {
1005
+ const state = d.state === "Booted" ? " [Booted]" : "";
1006
+ lines.push(` ${d.name} — ${d.udid}${state}`);
1007
+ }
1008
+ }
1009
+ if (lines.length === 0) {
1010
+ return { content: [{ type: "text", text: bootedOnly ? "No booted simulators." : "No simulators available." }] };
1011
+ }
1012
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1013
+ }
1014
+ catch (e) {
1015
+ return {
1016
+ content: [{ type: "text", text: `Error listing simulators: ${e.message}` }],
1017
+ isError: true,
1018
+ };
1019
+ }
1020
+ }
1021
+ async function handleBootSimulator(params) {
1022
+ const simulator = params.simulator;
1023
+ if (!simulator) {
1024
+ return {
1025
+ content: [{ type: "text", text: "Error: 'simulator' parameter is required" }],
1026
+ isError: true,
1027
+ };
1028
+ }
1029
+ try {
1030
+ // Check if it's a UDID or a name
1031
+ let udid = simulator;
1032
+ if (!simulator.match(/^[0-9A-F]{8}-/i)) {
1033
+ // It's a name, resolve to UDID
1034
+ udid = await findSimulatorUDID(simulator);
1035
+ }
1036
+ try {
1037
+ await runShell(`xcrun simctl boot "${udid}"`);
1038
+ }
1039
+ catch {
1040
+ // Already booted
1041
+ }
1042
+ await runShell("open -a Simulator");
1043
+ return { content: [{ type: "text", text: `Booted simulator: ${simulator} (${udid})` }] };
1044
+ }
1045
+ catch (e) {
1046
+ return {
1047
+ content: [{ type: "text", text: `Error: ${e.message}` }],
1048
+ isError: true,
1049
+ };
1050
+ }
1051
+ }
1052
+ // Register tool list handler
1053
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
1054
+ tools: TOOLS,
1055
+ }));
1056
+ // Register tool call handler
1057
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
1058
+ const { name, arguments: args } = request.params;
1059
+ const params = (args ?? {});
1060
+ const targetId = params.target;
1061
+ // Map MCP tool name to Nerve command
1062
+ const command = name.replace("nerve_", "");
1063
+ // --- Mac-side commands (don't go through WebSocket) ---
1064
+ if (command === "build" || command === "run") {
1065
+ return handleBuildRun(command, params);
1066
+ }
1067
+ // Grant permissions via simctl (Mac-side)
1068
+ if (command === "grant_permissions") {
1069
+ return handleGrantPermissions(params);
1070
+ }
1071
+ // LLDB debugger (Mac-side)
1072
+ if (command === "lldb") {
1073
+ return handleLLDB(params);
1074
+ }
1075
+ // Simulator management (Mac-side)
1076
+ if (command === "list_simulators") {
1077
+ return handleListSimulators(params);
1078
+ }
1079
+ if (command === "boot_simulator") {
1080
+ return handleBootSimulator(params);
1081
+ }
1082
+ // Deeplink via simctl (Mac-side, when method is "simctl")
1083
+ if (command === "deeplink" && (params.method === "simctl" || !connection.getConnectedTargets().length)) {
1084
+ return handleDeeplinkSimctl(params);
1085
+ }
1086
+ // Special case: status doesn't need a connection
1087
+ if (command === "status") {
1088
+ await connection.discover();
1089
+ const targets = connection.getConnectedTargets();
1090
+ if (targets.length === 0) {
1091
+ return {
1092
+ content: [
1093
+ {
1094
+ type: "text",
1095
+ text: "No Nerve instances found.\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
+ },
1097
+ ],
1098
+ };
1099
+ }
1100
+ // Get status from each connected target
1101
+ const results = [];
1102
+ for (const target of targets) {
1103
+ try {
1104
+ const result = await connection.send("status", {}, target.id);
1105
+ results.push(result);
1106
+ }
1107
+ catch (e) {
1108
+ results.push(`${target.id}: error — ${e.message}`);
1109
+ }
1110
+ }
1111
+ return {
1112
+ content: [{ type: "text", text: results.join("\n\n") }],
1113
+ };
1114
+ }
1115
+ try {
1116
+ // Remove device 'target' from params before forwarding
1117
+ const { target: _, target_screen, ...commandParams } = params;
1118
+ // For navigate: rename target_screen → target for the framework
1119
+ if (command === "navigate" && target_screen) {
1120
+ commandParams.target = target_screen;
1121
+ }
1122
+ const result = await connection.send(command, commandParams, targetId);
1123
+ // For screenshots, check if the result is base64 image data
1124
+ if (command === "screenshot" && result.startsWith("data:image/")) {
1125
+ const base64 = result.replace("data:image/png;base64,", "");
1126
+ return {
1127
+ content: [
1128
+ {
1129
+ type: "image",
1130
+ data: base64,
1131
+ mimeType: "image/png",
1132
+ },
1133
+ ],
1134
+ };
1135
+ }
1136
+ return {
1137
+ content: [{ type: "text", text: result }],
1138
+ };
1139
+ }
1140
+ catch (e) {
1141
+ const error = e;
1142
+ return {
1143
+ content: [{ type: "text", text: `Error: ${error.message}` }],
1144
+ isError: true,
1145
+ };
1146
+ }
1147
+ });
1148
+ // --- Build & Run (Mac-side, no WebSocket) ---
1149
+ function runShell(cmd, timeoutMs = 120000) {
1150
+ return new Promise((resolve, reject) => {
1151
+ const proc = (0, child_process_1.spawn)("bash", ["-c", cmd], { timeout: timeoutMs });
1152
+ let stdout = "";
1153
+ let stderr = "";
1154
+ proc.stdout?.on("data", (d) => { stdout += d.toString(); });
1155
+ proc.stderr?.on("data", (d) => { stderr += d.toString(); });
1156
+ proc.on("close", (code) => {
1157
+ if (code === 0)
1158
+ resolve(stdout + (stderr ? `\n${stderr}` : ""));
1159
+ else
1160
+ reject(new Error(`Exit code ${code}\n${stderr || stdout}`));
1161
+ });
1162
+ proc.on("error", reject);
1163
+ });
1164
+ }
1165
+ async function findSimulatorUDID(name) {
1166
+ const json = await runShell("xcrun simctl list devices available -j");
1167
+ const data = JSON.parse(json);
1168
+ for (const [, devices] of Object.entries(data.devices)) {
1169
+ for (const d of devices) {
1170
+ if (d.name === name)
1171
+ return d.udid;
1172
+ }
1173
+ }
1174
+ throw new Error(`Simulator '${name}' not found`);
1175
+ }
1176
+ async function handleBuildRun(command, params) {
1177
+ const scheme = params.scheme;
1178
+ const simulator = params.simulator || "iPhone 16 Pro";
1179
+ const workspace = params.workspace;
1180
+ const project = params.project;
1181
+ if (!scheme) {
1182
+ return {
1183
+ content: [{ type: "text", text: "Error: 'scheme' parameter is required" }],
1184
+ isError: true,
1185
+ };
1186
+ }
1187
+ const log = [];
1188
+ try {
1189
+ // Build args
1190
+ let buildSource = "";
1191
+ if (workspace)
1192
+ buildSource = `-workspace "${workspace}"`;
1193
+ else if (project)
1194
+ buildSource = `-project "${project}"`;
1195
+ const derivedData = "/tmp/nerve-derived-data";
1196
+ const buildCmd = `xcodebuild build ${buildSource} -scheme "${scheme}" -sdk iphonesimulator -configuration Debug -derivedDataPath "${derivedData}" -quiet 2>&1 | tail -5`;
1197
+ log.push(`Building ${scheme} for simulator...`);
1198
+ const buildOutput = await runShell(buildCmd, 300000);
1199
+ if (buildOutput.trim())
1200
+ log.push(buildOutput.trim());
1201
+ log.push("Build succeeded.");
1202
+ if (command === "build") {
1203
+ return { content: [{ type: "text", text: log.join("\n") }] };
1204
+ }
1205
+ // --- Run: install + launch with Nerve injection ---
1206
+ // Find the .app bundle
1207
+ const appPath = (await runShell(`find "${derivedData}/Build/Products/Debug-iphonesimulator" -name "*.app" -maxdepth 1 | head -1`)).trim();
1208
+ if (!appPath) {
1209
+ return {
1210
+ content: [{ type: "text", text: log.join("\n") + "\nError: Could not find .app bundle" }],
1211
+ isError: true,
1212
+ };
1213
+ }
1214
+ const bundleId = (await runShell(`/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`)).trim();
1215
+ log.push(`App: ${bundleId}`);
1216
+ // Find simulator
1217
+ const udid = await findSimulatorUDID(simulator);
1218
+ log.push(`Simulator: ${simulator} (${udid})`);
1219
+ // Boot simulator if needed
1220
+ try {
1221
+ await runShell(`xcrun simctl boot "${udid}" 2>/dev/null`);
1222
+ log.push("Booted simulator.");
1223
+ }
1224
+ catch {
1225
+ // Already booted
1226
+ }
1227
+ // Install
1228
+ await runShell(`xcrun simctl install "${udid}" "${appPath}"`);
1229
+ log.push("Installed app.");
1230
+ // Stop existing instance if running
1231
+ try {
1232
+ await runShell(`xcrun simctl terminate "${udid}" "${bundleId}" 2>/dev/null`);
1233
+ }
1234
+ catch {
1235
+ // Not running
1236
+ }
1237
+ // Clean up old port file
1238
+ try {
1239
+ await runShell(`rm -f "/tmp/nerve-ports/${udid}-${bundleId}.json"`);
1240
+ }
1241
+ catch { /* ignore */ }
1242
+ // Launch
1243
+ await runShell(`xcrun simctl launch "${udid}" "${bundleId}"`);
1244
+ log.push("Launched.");
1245
+ // Wait for Nerve to be ready (app must have Nerve via SPM)
1246
+ const portFile = `/tmp/nerve-ports/${udid}-${bundleId}.json`;
1247
+ for (let i = 0; i < 30; i++) {
1248
+ if (fs.existsSync(portFile)) {
1249
+ const info = JSON.parse(fs.readFileSync(portFile, "utf-8"));
1250
+ log.push(`Nerve ready on port ${info.port}`);
1251
+ await connection.discover();
1252
+ break;
1253
+ }
1254
+ await new Promise(r => setTimeout(r, 500));
1255
+ }
1256
+ if (!fs.existsSync(portFile)) {
1257
+ log.push("Nerve did not start. Ensure your app includes the Nerve SPM package with Nerve.start() in #if DEBUG.");
1258
+ }
1259
+ return { content: [{ type: "text", text: log.join("\n") }] };
1260
+ }
1261
+ catch (e) {
1262
+ const error = e;
1263
+ log.push(`Error: ${error.message}`);
1264
+ return {
1265
+ content: [{ type: "text", text: log.join("\n") }],
1266
+ isError: true,
1267
+ };
1268
+ }
1269
+ }
1270
+ // --- Grant Permissions (Mac-side) ---
1271
+ async function handleGrantPermissions(params) {
1272
+ const services = params.services;
1273
+ if (!services || services.length === 0) {
1274
+ return {
1275
+ content: [{ type: "text", text: "Error: 'services' parameter is required (e.g., ['camera', 'photos'] or ['all'])" }],
1276
+ isError: true,
1277
+ };
1278
+ }
1279
+ // Find connected target to get UDID and bundle ID
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) {
1300
+ return {
1301
+ content: [{ type: "text", text: "Error: No running Nerve instance found. Launch the app first." }],
1302
+ isError: true,
1303
+ };
1304
+ }
1305
+ const log = [];
1306
+ for (const service of services) {
1307
+ try {
1308
+ await runShell(`xcrun simctl privacy "${udid}" grant ${service} "${bundleId}" 2>&1`);
1309
+ log.push(`Granted: ${service}`);
1310
+ }
1311
+ catch (e) {
1312
+ log.push(`Failed: ${service} — ${e.message.split("\n")[0]}`);
1313
+ }
1314
+ }
1315
+ return { content: [{ type: "text", text: log.join("\n") }] };
1316
+ }
1317
+ // --- Deeplink via simctl (Mac-side) ---
1318
+ async function handleDeeplinkSimctl(params) {
1319
+ const url = params.url;
1320
+ if (!url) {
1321
+ return {
1322
+ content: [{ type: "text", text: "Error: 'url' parameter is required" }],
1323
+ isError: true,
1324
+ };
1325
+ }
1326
+ // Find UDID
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
+ }
1342
+ if (!udid) {
1343
+ // Try booted simulators
1344
+ try {
1345
+ const json = JSON.parse(await runShell("xcrun simctl list devices available -j"));
1346
+ for (const [, devices] of Object.entries(json.devices)) {
1347
+ for (const d of devices) {
1348
+ if (d.state === "Booted") {
1349
+ udid = d.udid;
1350
+ break;
1351
+ }
1352
+ }
1353
+ if (udid)
1354
+ break;
1355
+ }
1356
+ }
1357
+ catch { }
1358
+ }
1359
+ if (!udid) {
1360
+ return {
1361
+ content: [{ type: "text", text: "Error: No simulator found" }],
1362
+ isError: true,
1363
+ };
1364
+ }
1365
+ try {
1366
+ await runShell(`xcrun simctl openurl "${udid}" "${url}"`);
1367
+ return { content: [{ type: "text", text: `Opened URL: ${url}` }] };
1368
+ }
1369
+ catch (e) {
1370
+ return {
1371
+ content: [{ type: "text", text: `Error opening URL: ${e.message}` }],
1372
+ isError: true,
1373
+ };
1374
+ }
1375
+ }
1376
+ // --- Main ---
1377
+ async function main() {
1378
+ // Initial discovery
1379
+ await connection.discover();
1380
+ // Start MCP server on stdio
1381
+ const transport = new stdio_js_1.StdioServerTransport();
1382
+ await server.connect(transport);
1383
+ console.error("[nerve] MCP server started");
1384
+ // Periodic re-discovery
1385
+ setInterval(() => connection.discover(), 3000);
1386
+ }
1387
+ main().catch((e) => {
1388
+ console.error("[nerve] Fatal:", e);
1389
+ process.exit(1);
1390
+ });