@mobilenext/mobile-mcp 0.0.19 → 0.0.20

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/README.md CHANGED
@@ -175,7 +175,7 @@ What you will need to connect MCP with your agent and mobile devices:
175
175
 
176
176
  - [Xcode command line tools](https://developer.apple.com/xcode/resources/)
177
177
  - [Android Platform Tools](https://developer.android.com/tools/releases/platform-tools)
178
- - [node.js](https://nodejs.org/en/download/)
178
+ - [node.js](https://nodejs.org/en/download/) v22+
179
179
  - [MCP](https://modelcontextprotocol.io/introduction) supported foundational models or agents, like [Claude MCP](https://modelcontextprotocol.io/quickstart/server), [OpenAI Agent SDK](https://openai.github.io/openai-agents-python/mcp/), [Copilot Studio](https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/introducing-model-context-protocol-mcp-in-copilot-studio-simplified-integration-with-ai-apps-and-agents/)
180
180
 
181
181
  ### Simulators, Emulators, and Physical Devices
package/lib/android.js CHANGED
@@ -94,6 +94,7 @@ class AndroidRobot {
94
94
  return { width, height, scale };
95
95
  }
96
96
  async listApps() {
97
+ // only apps that have a launcher activity are returned
97
98
  return this.adb("shell", "cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER")
98
99
  .toString()
99
100
  .split("\n")
@@ -106,6 +107,13 @@ class AndroidRobot {
106
107
  appName: packageName,
107
108
  }));
108
109
  }
110
+ async listPackages() {
111
+ return this.adb("shell", "pm", "list", "packages")
112
+ .toString()
113
+ .split("\n")
114
+ .filter(line => line.startsWith("package:"))
115
+ .map(line => line.substring("package:".length));
116
+ }
109
117
  async launchApp(packageName) {
110
118
  this.adb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
111
119
  }
@@ -229,10 +237,37 @@ class AndroidRobot {
229
237
  async openUrl(url) {
230
238
  this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url);
231
239
  }
240
+ isAscii(text) {
241
+ return /^[\x00-\x7F]*$/.test(text);
242
+ }
243
+ async isDeviceKitInstalled() {
244
+ const packages = await this.listPackages();
245
+ return packages.includes("com.mobilenext.devicekit");
246
+ }
232
247
  async sendKeys(text) {
233
- // adb shell requires some escaping
234
- const _text = text.replace(/ /g, "\\ ");
235
- this.adb("shell", "input", "text", _text);
248
+ if (text === "") {
249
+ // bailing early, so we don't run adb shell with empty string.
250
+ // this happens when you prompt with a simple "submit".
251
+ return;
252
+ }
253
+ if (this.isAscii(text)) {
254
+ // adb shell input only supports ascii characters. and
255
+ // some of the keys have to be escaped.
256
+ const _text = text.replace(/ /g, "\\ ");
257
+ this.adb("shell", "input", "text", _text);
258
+ }
259
+ else if (await this.isDeviceKitInstalled()) {
260
+ // try sending over clipboard
261
+ const base64 = Buffer.from(text).toString("base64");
262
+ // send clipboard over and immediately paste it
263
+ this.adb("shell", "am", "broadcast", "-a", "devicekit.clipboard.set", "-e", "encoding", "base64", "-e", "text", base64, "-n", "com.mobilenext.devicekit/.ClipboardBroadcastReceiver");
264
+ this.adb("shell", "input", "keyevent", "KEYCODE_PASTE");
265
+ // clear clipboard when we're done
266
+ this.adb("shell", "am", "broadcast", "-a", "devicekit.clipboard.clear", "-n", "com.mobilenext.devicekit/.ClipboardBroadcastReceiver");
267
+ }
268
+ else {
269
+ throw new robot_1.ActionableError("Non-ASCII text is not supported on Android, please install mobilenext devicekit, see https://github.com/mobile-next/devicekit-android");
270
+ }
236
271
  }
237
272
  async pressButton(button) {
238
273
  if (!BUTTON_MAP[button]) {
@@ -245,8 +280,9 @@ class AndroidRobot {
245
280
  }
246
281
  async setOrientation(orientation) {
247
282
  const orientationValue = orientation === "portrait" ? 0 : 1;
248
- this.adb("shell", "content", "insert", "--uri", "content://settings/system", "--bind", "name:s:user_rotation", "--bind", `value:i:${orientationValue}`);
283
+ // disable auto-rotation prior to setting the orientation
249
284
  this.adb("shell", "settings", "put", "system", "accelerometer_rotation", "0");
285
+ this.adb("shell", "content", "insert", "--uri", "content://settings/system", "--bind", "name:s:user_rotation", "--bind", `value:i:${orientationValue}`);
250
286
  }
251
287
  async getOrientation() {
252
288
  const rotation = this.adb("shell", "settings", "get", "system", "user_rotation").toString().trim();
package/lib/ios.js CHANGED
@@ -1,13 +1,6 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.IosManager = exports.IosRobot = void 0;
7
- const path_1 = __importDefault(require("path"));
8
- const os_1 = require("os");
9
- const crypto_1 = require("crypto");
10
- const fs_1 = require("fs");
11
4
  const child_process_1 = require("child_process");
12
5
  const net_1 = require("net");
13
6
  const webdriver_agent_1 = require("./webdriver-agent");
@@ -129,12 +122,16 @@ class IosRobot {
129
122
  return await wda.getElementsOnScreen();
130
123
  }
131
124
  async getScreenshot() {
125
+ const wda = await this.wda();
126
+ return await wda.getScreenshot();
127
+ /* alternative:
132
128
  await this.assertTunnelRunning();
133
- const tmpFilename = path_1.default.join((0, os_1.tmpdir)(), `screenshot-${(0, crypto_1.randomBytes)(8).toString("hex")}.png`);
129
+ const tmpFilename = path.join(tmpdir(), `screenshot-${randomBytes(8).toString("hex")}.png`);
134
130
  await this.ios("screenshot", "--output", tmpFilename);
135
- const buffer = (0, fs_1.readFileSync)(tmpFilename);
136
- (0, fs_1.unlinkSync)(tmpFilename);
131
+ const buffer = readFileSync(tmpFilename);
132
+ unlinkSync(tmpFilename);
137
133
  return buffer;
134
+ */
138
135
  }
139
136
  async setOrientation(orientation) {
140
137
  const wda = await this.wda();
@@ -157,23 +154,23 @@ class IosManager {
157
154
  return false;
158
155
  }
159
156
  }
160
- async getDeviceName(deviceId) {
157
+ getDeviceName(deviceId) {
161
158
  const output = (0, child_process_1.execFileSync)(getGoIosPath(), ["info", "--udid", deviceId]).toString();
162
159
  const json = JSON.parse(output);
163
160
  return json.DeviceName;
164
161
  }
165
- async listDevices() {
166
- if (!(await this.isGoIosInstalled())) {
162
+ listDevices() {
163
+ if (!this.isGoIosInstalled()) {
167
164
  console.error("go-ios is not installed, no physical iOS devices can be detected");
168
165
  return [];
169
166
  }
170
167
  const output = (0, child_process_1.execFileSync)(getGoIosPath(), ["list"]).toString();
171
168
  const json = JSON.parse(output);
172
- const devices = json.deviceList.map(async (device) => ({
169
+ const devices = json.deviceList.map(device => ({
173
170
  deviceId: device,
174
- deviceName: await this.getDeviceName(device),
171
+ deviceName: this.getDeviceName(device),
175
172
  }));
176
- return Promise.all(devices);
173
+ return devices;
177
174
  }
178
175
  }
179
176
  exports.IosManager = IosManager;
@@ -26,7 +26,9 @@ class Simctl {
26
26
  });
27
27
  }
28
28
  async getScreenshot() {
29
- return this.simctl("io", this.simulatorUuid, "screenshot", "-");
29
+ const wda = await this.wda();
30
+ return await wda.getScreenshot();
31
+ // alternative: return this.simctl("io", this.simulatorUuid, "screenshot", "-");
30
32
  }
31
33
  async openUrl(url) {
32
34
  const wda = await this.wda();
package/lib/server.js CHANGED
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.createMcpServer = exports.getAgentVersion = void 0;
4
7
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
8
  const zod_1 = require("zod");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_crypto_1 = __importDefault(require("node:crypto"));
6
11
  const logger_1 = require("./logger");
7
12
  const android_1 = require("./android");
8
13
  const robot_1 = require("./robot");
@@ -41,6 +46,7 @@ const createMcpServer = () => {
41
46
  tools: {},
42
47
  },
43
48
  });
49
+ // an empty object to satisfy windsurf
44
50
  const noParams = zod_1.z.object({});
45
51
  const tool = (name, description, paramsSchema, cb) => {
46
52
  const wrappedCb = async (args) => {
@@ -70,6 +76,27 @@ const createMcpServer = () => {
70
76
  };
71
77
  server.tool(name, description, paramsSchema, args => wrappedCb(args));
72
78
  };
79
+ const posthog = (event, properties) => {
80
+ const url = "https://us.i.posthog.com/i/v0/e/";
81
+ const api_key = "phc_KHRTZmkDsU7A8EbydEK8s4lJpPoTDyyBhSlwer694cS";
82
+ const distinct_id = node_crypto_1.default.createHash("sha256").update(process.execPath).digest("hex");
83
+ fetch(url, {
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json"
87
+ },
88
+ body: JSON.stringify({
89
+ api_key,
90
+ event,
91
+ properties,
92
+ distinct_id,
93
+ })
94
+ }).then().catch();
95
+ };
96
+ posthog("launch", {
97
+ Product: "mobile-mcp",
98
+ Version: (0, exports.getAgentVersion)(),
99
+ });
73
100
  let robot;
74
101
  const simulatorManager = new iphone_simulator_1.SimctlManager();
75
102
  const requireRobot = () => {
@@ -77,13 +104,44 @@ const createMcpServer = () => {
77
104
  throw new robot_1.ActionableError("No device selected. Use the mobile_use_device tool to select a device.");
78
105
  }
79
106
  };
107
+ tool("mobile_use_default_device", "Use the default device. This is a shortcut for mobile_use_device with deviceType=simulator and device=simulator_name", {
108
+ noParams
109
+ }, async () => {
110
+ const iosManager = new ios_1.IosManager();
111
+ const androidManager = new android_1.AndroidDeviceManager();
112
+ const simulators = simulatorManager.listBootedSimulators();
113
+ const androidDevices = androidManager.getConnectedDevices();
114
+ const iosDevices = iosManager.listDevices();
115
+ const sum = simulators.length + androidDevices.length + iosDevices.length;
116
+ if (sum === 0) {
117
+ throw new robot_1.ActionableError("No devices found. Please connect a device and try again.");
118
+ }
119
+ else if (sum >= 2) {
120
+ throw new robot_1.ActionableError("Multiple devices found. Please use the mobile_list_available_devices tool to list available devices and select one.");
121
+ }
122
+ // only one device connected, let's find it now
123
+ if (simulators.length === 1) {
124
+ robot = simulatorManager.getSimulator(simulators[0].name);
125
+ return `Selected default device: ${simulators[0].name}`;
126
+ }
127
+ else if (androidDevices.length === 1) {
128
+ robot = new android_1.AndroidRobot(androidDevices[0].deviceId);
129
+ return `Selected default device: ${androidDevices[0].deviceId}`;
130
+ }
131
+ else if (iosDevices.length === 1) {
132
+ robot = new ios_1.IosRobot(iosDevices[0].deviceId);
133
+ return `Selected default device: ${iosDevices[0].deviceId}`;
134
+ }
135
+ // how did this happen?
136
+ throw new robot_1.ActionableError("No device selected. Please use the mobile_list_available_devices tool to list available devices and select one.");
137
+ });
80
138
  tool("mobile_list_available_devices", "List all available devices. This includes both physical devices and simulators. If there is more than one device returned, you need to let the user select one of them.", {
81
139
  noParams
82
140
  }, async ({}) => {
83
141
  const iosManager = new ios_1.IosManager();
84
142
  const androidManager = new android_1.AndroidDeviceManager();
85
- const devices = simulatorManager.listBootedSimulators();
86
- const simulatorNames = devices.map(d => d.name);
143
+ const simulators = simulatorManager.listBootedSimulators();
144
+ const simulatorNames = simulators.map(d => d.name);
87
145
  const androidDevices = androidManager.getConnectedDevices();
88
146
  const iosDevices = await iosManager.listDevices();
89
147
  const iosDeviceNames = iosDevices.map(d => d.deviceId);
@@ -149,7 +207,7 @@ const createMcpServer = () => {
149
207
  const screenSize = await robot.getScreenSize();
150
208
  return `Screen size is ${screenSize.width}x${screenSize.height} pixels`;
151
209
  });
152
- tool("mobile_click_on_screen_at_coordinates", "Click on the screen at given x,y coordinates", {
210
+ tool("mobile_click_on_screen_at_coordinates", "Click on the screen at given x,y coordinates. If clicking on an element, use the list_elements_on_screen tool to find the coordinates.", {
153
211
  x: zod_1.z.number().describe("The x coordinate to click on the screen, in pixels"),
154
212
  y: zod_1.z.number().describe("The y coordinate to click on the screen, in pixels"),
155
213
  }, async ({ x, y }) => {
@@ -228,6 +286,14 @@ const createMcpServer = () => {
228
286
  }
229
287
  return `Typed text: ${text}`;
230
288
  });
289
+ tool("mobile_save_screenshot", "Save a screenshot of the mobile device to a file", {
290
+ saveTo: zod_1.z.string().describe("The path to save the screenshot to"),
291
+ }, async ({ saveTo }) => {
292
+ requireRobot();
293
+ const screenshot = await robot.getScreenshot();
294
+ node_fs_1.default.writeFileSync(saveTo, screenshot);
295
+ return `Screenshot saved to: ${saveTo}`;
296
+ });
231
297
  server.tool("mobile_take_screenshot", "Take a screenshot of the mobile device. Use this to understand what's on screen, if you need to press an element that is available through view hierarchy then you must list elements on screen instead. Do not cache this result.", {
232
298
  noParams
233
299
  }, async ({}) => {
@@ -191,6 +191,12 @@ class WebDriverAgent {
191
191
  });
192
192
  });
193
193
  }
194
+ async getScreenshot() {
195
+ const url = `http://${this.host}:${this.port}/screenshot`;
196
+ const response = await fetch(url);
197
+ const json = await response.json();
198
+ return Buffer.from(json.value, "base64");
199
+ }
194
200
  async swipe(direction) {
195
201
  await this.withinSession(async (sessionUrl) => {
196
202
  const screenSize = await this.getScreenSize(sessionUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobilenext/mobile-mcp",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Mobile MCP",
5
5
  "repository": {
6
6
  "type": "git",