@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 +1 -1
- package/lib/android.js +40 -4
- package/lib/ios.js +13 -16
- package/lib/iphone-simulator.js +3 -1
- package/lib/server.js +69 -3
- package/lib/webdriver-agent.js +6 -0
- package/package.json +1 -1
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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 =
|
|
129
|
+
const tmpFilename = path.join(tmpdir(), `screenshot-${randomBytes(8).toString("hex")}.png`);
|
|
134
130
|
await this.ios("screenshot", "--output", tmpFilename);
|
|
135
|
-
const buffer =
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
if (!
|
|
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(
|
|
169
|
+
const devices = json.deviceList.map(device => ({
|
|
173
170
|
deviceId: device,
|
|
174
|
-
deviceName:
|
|
171
|
+
deviceName: this.getDeviceName(device),
|
|
175
172
|
}));
|
|
176
|
-
return
|
|
173
|
+
return devices;
|
|
177
174
|
}
|
|
178
175
|
}
|
|
179
176
|
exports.IosManager = IosManager;
|
package/lib/iphone-simulator.js
CHANGED
|
@@ -26,7 +26,9 @@ class Simctl {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
async getScreenshot() {
|
|
29
|
-
|
|
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
|
|
86
|
-
const simulatorNames =
|
|
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 ({}) => {
|
package/lib/webdriver-agent.js
CHANGED
|
@@ -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);
|