nerve-mcp 0.1.0 → 0.2.0

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