Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "mobile-dev",
"version": "0.1.164",
"description": "Stream iOS and Android devices into the chat while building, running, or debugging mobile apps. Read native and Metro logs, inspect and control apps, and record CPU, memory, and FPS. Apple Silicon development preview.",
"author": {
"name": "Callstack",
"email": "hello@callstack.com"
},
"homepage": "https://github.com/callstackincubator/codex-mobile-dev-plugin",
"repository": "https://github.com/callstackincubator/codex-mobile-dev-plugin",
"license": "MIT",
"keywords": ["ios", "android", "simulator", "emulator", "react-native", "expo", "logs", "performance"],
"logo": "assets/icon.svg",
"mcpServers": {
"mobile-dev": {
"command": "node",
"args": ["${CURSOR_PLUGIN_ROOT}/dist/server.mjs"]
}
}
}
35 changes: 35 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,41 @@ duplicate servers. Rebuild, repackage, and reinstall to update a bundled copy.
Local builds retain the `development` telemetry environment. Public releases use
`npm run build:release` and `npm run package:claude -- --release`.

### Cursor local development

Complete the first-time runtime and native-helper setup under [Build and package](#build-and-package),
then run from this checkout with Node.js 22.19 or later:

```sh
npm run build
npm run cursor:config
```

1. Merge the `mobile-dev` entry from `.local-dev/cursor-mcp.json` into the
`mcpServers` object in `~/.cursor/mcp.json`, preserving other servers. Use the
global file rather than a project's `.cursor/mcp.json`: the snippet uses absolute
paths to this checkout and the Node executable running the generator.
2. Reload Cursor, check that `mobile-dev` is enabled in the MCP section of Cursor Settings, and
ask **“Open Mobile Dev”** in a new agent chat.

After UI or server edits, run `npm run build` and reload Cursor. Regenerate the
config if the checkout or Node path changes.

To create and check a plugin folder for manual installation instead:

```sh
npm run package:cursor
npm run test:cursor
```

This writes the Cursor plugin folder `release/cursor-plugin/mobile-dev`. Its
`.cursor-plugin/plugin.json` starts `${CURSOR_PLUGIN_ROOT}/dist/server.mjs` with
Node.js from `PATH` and leaves out the Codex manifest, `.mcp.json`, and launcher.
Follow the [manual installation steps](README.md#cursor-manual-installation).
Remove the `mobile-dev` development entry before installing the plugin to avoid
duplicate servers. Public releases use `npm run build:release` and
`npm run package:cursor -- --release`.

## Live React UI development

In Codex, run `npm run dev` after installing the plugin. It watches the UI sources and
Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Keep iOS and Android devices beside your chat while building, running, and
debugging mobile apps. Mobile Dev works with Expo, React Native, SwiftUI, and
other native mobile projects.

Mobile Dev is an MCP plugin that works with both Codex and Claude. Codex shows it
beside the chat; Claude opens it in the conversation, with an expanded view when
available. Claude support is a development preview.
Mobile Dev is an MCP plugin that works with Codex, Claude, and Cursor. Codex shows it
beside the chat; Claude and Cursor open it in the conversation, with an expanded view
when available. Claude and Cursor support is a development preview.

- [Live devices beside your chat](#live-devices-beside-your-chat): stream simulators, emulators, and physical devices.
- [Device control, inspection and annotations](#device-control-inspection-and-annotations): tap, type, inspect elements, and leave notes for the agent.
Expand Down Expand Up @@ -57,6 +57,24 @@ The bundle includes the plugin and native helpers; Claude supplies Node, so no
the bundle or the [local development connection](CONTRIBUTING.md#claude-desktop-local-development)
to avoid duplicate Mobile Dev servers. See [Claude's installation guide](https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop).

### Cursor: manual installation

On an Apple Silicon Mac with Node.js 22.19 or later on `PATH`, obtain the
`mobile-dev` Cursor plugin folder from the person sharing the preview, or
[build one locally](CONTRIBUTING.md#cursor-local-development). The Cursor plugin is
not yet published to the Cursor Marketplace.

1. Copy the `mobile-dev` folder to `~/.cursor/plugins/local/mobile-dev`.
2. Run **Developer: Reload Window**, or restart Cursor, and confirm Mobile Dev
appears under **Customize**.
3. Start a new agent chat and ask **“Open Mobile Dev.”** Approve the tool calls
when prompted.

Cursor renders the Mobile Dev views where MCP Apps are available. Otherwise tools
return text, and the agent asks you to choose devices in chat. Copy a newer folder
and reload to update. Use either the plugin or the [local development connection](CONTRIBUTING.md#cursor-local-development)
to avoid duplicate Mobile Dev servers. See [Cursor's plugin guide](https://cursor.com/docs/plugins).

## Features

### Live devices beside your chat
Expand Down Expand Up @@ -94,7 +112,8 @@ See [storage privacy and review checks](docs/storage-privacy.md).
The prebuilt plugin targets Codex desktop on an Apple Silicon Mac and uses
Codex's bundled Node runtime from its workspace dependency cache. The Claude
Desktop preview also targets Apple Silicon and uses Claude's Node runtime when
installed as a bundle. Install the tools for the platforms you use:
installed as a bundle. The Cursor preview targets Apple Silicon and uses Node.js
from `PATH`. Install the tools for the platforms you use:

| Platform or feature | Requirements |
| --- | --- |
Expand All @@ -117,7 +136,8 @@ its device panel, inspection, logs, and performance tools.

In Codex, open Mobile Dev from the sidebar or ask to open the simulator beside
this chat. In Claude Desktop, ask **“Open Mobile Dev”** in a conversation with the
connector enabled, then use **Expand** when available.
connector enabled, then use **Expand** when available. In Cursor, ask **“Open
Mobile Dev”** in an agent chat.

Pick a device from the toolbar; selecting a stopped simulator or AVD
boots it. Use the iOS and Android toggles to show either platform or both.
Expand Down
2 changes: 1 addition & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ checks GitHub's latest full release and the public `release/latest` manifest;
their versions must agree before the banner offers an update. Successful checks
are cached for one hour across chats in the active Codex profile; unavailable
checks retry after fifteen minutes. Development builds, and installs that are
not a Codex plugin package (such as the Claude MCPB bundle), do not check or
not a Codex plugin package (such as the Claude MCPB bundle or the Cursor plugin), do not check or
install public updates. The embedded UI needs no GitHub network permissions.

The Update button calls an app-only MCP tool. It locates the CLI process that
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
"claude:config": "node scripts/claude-config.mjs",
"package:claude": "node scripts/package-claude.mjs",
"test:claude": "node scripts/smoke-claude-package.mjs",
"cursor:config": "node scripts/cursor-config.mjs",
"package:cursor": "node scripts/package-cursor.mjs",
"test:cursor": "node scripts/smoke-cursor-package.mjs",
"vendor:serve-emu": "node scripts/vendor-serve-emu.mjs",
"dev": "node scripts/dev-ui.mjs",
"dev:off": "node scripts/dev-ui.mjs --off",
Expand Down
18 changes: 18 additions & 0 deletions scripts/cursor-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { access, mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";

// Generate a snippet only; never replace the user's existing Cursor configuration.
await access("dist/server.mjs");
const config = {
mcpServers: {
"mobile-dev": {
type: "stdio",
command: process.execPath,
args: [resolve("dist/server.mjs")],
},
},
};
await mkdir(".local-dev", { recursive: true });
const path = resolve(".local-dev/cursor-mcp.json");
await writeFile(path, JSON.stringify(config, null, 2) + "\n");
console.log(`Merge the mcpServers entry from ${path} into ~/.cursor/mcp.json, then reload Cursor.`);
23 changes: 23 additions & 0 deletions scripts/package-cursor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { execFileSync } from "node:child_process";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { telemetryBuildEnvironment } from "./telemetry-build.mjs";

// Reuse the existing artifact inventory, native assets and environment checks.
telemetryBuildEnvironment();
execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" });
const metadata = JSON.parse(await readFile("package.json", "utf8"));
// Cursor plugin: the same payload without the Codex manifest, .mcp.json or launcher.
// The server starts from PATH Node, and the updater stays off outside Codex packages.
const plugin = JSON.parse(await readFile(".cursor-plugin/plugin.json", "utf8"));
plugin.version = metadata.version;
const source = resolve("release/marketplace/plugins/mobile-dev");
const documents = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"];
const directory = resolve(`release/cursor-plugin/${plugin.name}`);
await rm(directory, { recursive: true, force: true });
await mkdir(`${directory}/.cursor-plugin`, { recursive: true });
for (const path of ["dist", "assets", "skills", ...documents]) {
await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true });
}
await writeFile(`${directory}/.cursor-plugin/plugin.json`, JSON.stringify(plugin, null, 2) + "\n");
console.log(`Cursor plugin: ${directory}`);
66 changes: 66 additions & 0 deletions scripts/smoke-cursor-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { access, cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const metadata = JSON.parse(await readFile("package.json", "utf8"));
const temporary = await mkdtemp(join(tmpdir(), "mobile-dev-cursor-"));
try {
// Copy the plugin to a path with spaces, as ~/.cursor/plugins/local or an install cache would.
const plugin = join(temporary, "plugin with spaces");
await cp(resolve(process.argv[2] ?? "release/cursor-plugin/mobile-dev"), plugin, { recursive: true, verbatimSymlinks: true });
const manifest = JSON.parse(await readFile(join(plugin, ".cursor-plugin/plugin.json"), "utf8"));
assert.equal(manifest.name, "mobile-dev");
assert.equal(manifest.version, metadata.version);
assert.equal(manifest.license, "MIT");
await access(join(plugin, manifest.logo));
const server = manifest.mcpServers["mobile-dev"];
assert.equal(server.command, "node");
assert.deepEqual(server.args, ["${CURSOR_PLUGIN_ROOT}/dist/server.mjs"]);
for (const path of ["LICENSE", "README.md", "PRIVACY.md", "skills/mobile-dev/SKILL.md", "skills/mobile-dev-setup/SKILL.md", "dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node"]) {
await access(join(plugin, path));
}
// Codex-only files would load the Codex launcher or enable the Codex updater.
for (const path of [".mcp.json", "mcp.json", ".codex-plugin", ".claude-plugin", "scripts/launch-mcp.sh"]) {
await assert.rejects(access(join(plugin, path)), path);
}
const home = join(temporary, "home");
await mkdir(home);
// Behavior follows client capabilities, not the host's name: without forms or
// MCP Apps, device choices fall back to chat.
for (const environment of ["development", "release"]) {
const client = new Client({ name: "cursor-plugin-smoke", version: "1" }, { capabilities: {} });
const transport = new StdioClientTransport({
command: process.execPath, args: server.args.map(arg => arg.replaceAll("${CURSOR_PLUGIN_ROOT}", plugin)),
cwd: temporary, stderr: "pipe",
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home,
MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: environment },
});
let diagnostics = "";
transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-5000); });
try {
await client.connect(transport);
assert.match(client.getInstructions(), /in chat when it is unsupported/);
const { tools } = await client.listTools();
for (const name of ["mobile_show_devices", "mobile_open_workspace", "mobile_open_simulator", "mobile_list_simulators", "mobile_list_android_devices"]) {
assert.ok(tools.some(tool => tool.name === name), name);
}
const workspace = tools.find(tool => tool.name === "mobile_open_workspace");
const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri });
const html = resource.contents[0].text;
assert.ok(html.includes(`name="mobile-dev-environment" content="${environment}"`));
assert.match(html, /name="mobile-dev-telemetry" content="off"/);
const choice = await client.callTool({ name: "mobile_show_devices", arguments: { message: "Which device?", selectionMode: "single" } });
assert.equal(choice.isError, true);
assert.match(choice.content[0].text, /ask the user in chat/);
const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} });
assert.equal(check.structuredContent.update.status, "disabled");
console.log(`${environment}: Cursor plugin manifest, payload, MCP startup from the plugin root, chat fallback and disabled updater passed.`);
} catch (error) {
if (diagnostics) console.error(diagnostics);
throw error;
} finally { await client.close(); await transport.close(); }
}
} finally { await rm(temporary, { recursive: true, force: true }); }
Loading