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.
Files changed (2) hide show
  1. package/dist/index.js +413 -337
  2. 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
- // --- 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
- }
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 targets;
55
+ return 10000 + (hash % 55000);
83
56
  }
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
- }
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
- return targets;
67
+ throw new Error(`Unknown target format: ${targetId}`);
98
68
  }
99
- catch {
100
- return [];
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
- // --- 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}`;
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 pingInterval;
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
- 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);
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
- 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
- }
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
- // Ignore malformed messages
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 (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
- }
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
- - After navigation, call nerve_view to see the new screen.
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 for visual confirmation.
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 to press buttons, select items, navigate, or focus text fields. Use #identifier (most reliable), @label (by visible text), or x,y coordinates.",
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
- // 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
- }
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
- 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
- }
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: No running app found. Launch the app first with nerve_run." }],
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" || !connection.getConnectedTargets().length)) {
989
+ if (command === "deeplink" && (params.method === "simctl" || !activeTarget)) {
1084
990
  return handleDeeplinkSimctl(params);
1085
991
  }
1086
- // Special case: status doesn't need a connection
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
- await connection.discover();
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 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.",
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
- // 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
- }
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 connection.send(command, commandParams, targetId);
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
- 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`;
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
- const buildOutput = await runShell(buildCmd, 300000);
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
- // Find the .app bundle
1207
- const appPath = (await runShell(`find "${derivedData}/Build/Products/Debug-iphonesimulator" -name "*.app" -maxdepth 1 | head -1`)).trim();
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
- // 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();
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, 500));
1194
+ await new Promise(r => setTimeout(r, 250));
1255
1195
  }
1256
- if (!fs.existsSync(portFile)) {
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
- // 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) {
1394
+ if (!activeTarget) {
1300
1395
  return {
1301
- content: [{ type: "text", text: "Error: No running Nerve instance found. Launch the app first." }],
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
- // 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
- }
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerve-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for Nerve — gives AI agents runtime access to iOS apps",
5
5
  "main": "dist/index.js",
6
6
  "bin": {