From d61afb0e3b95034da0f379ac4e846b69678f28c5 Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Sat, 24 Oct 2026 13:02:18 +0400 Subject: [PATCH] feat(cursor): package mobile dev as a cursor plugin Add a Cursor plugin manifest that starts the server with Node from PATH via ${CURSOR_PLUGIN_ROOT}. package:cursor writes release/cursor-plugin/mobile-dev without Codex-only files, and test:cursor starts the server from that folder with no client capabilities to check the chat fallback and disabled updater. cursor:config writes an mcp.json snippet for ~/.cursor/mcp.json when running from a checkout. Co-Authored-By: Claude Opus 5.5 --- .cursor-plugin/plugin.json | 20 ++++++++++ CONTRIBUTING.md | 35 +++++++++++++++++ README.md | 30 ++++++++++++--- docs/releases.md | 2 +- package.json | 3 ++ scripts/cursor-config.mjs | 18 +++++++++ scripts/package-cursor.mjs | 23 +++++++++++ scripts/smoke-cursor-package.mjs | 66 ++++++++++++++++++++++++++++++++ 8 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 .cursor-plugin/plugin.json create mode 100644 scripts/cursor-config.mjs create mode 100644 scripts/package-cursor.mjs create mode 100644 scripts/smoke-cursor-package.mjs diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..cc18a97 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -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"] + } + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2389b4..0e576e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 1e130e8..c129e13 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 | | --- | --- | @@ -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. diff --git a/docs/releases.md b/docs/releases.md index b1b6939..9b7a480 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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 diff --git a/package.json b/package.json index 351c1db..e78ad1a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/cursor-config.mjs b/scripts/cursor-config.mjs new file mode 100644 index 0000000..2785675 --- /dev/null +++ b/scripts/cursor-config.mjs @@ -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.`); diff --git a/scripts/package-cursor.mjs b/scripts/package-cursor.mjs new file mode 100644 index 0000000..1c95621 --- /dev/null +++ b/scripts/package-cursor.mjs @@ -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}`); diff --git a/scripts/smoke-cursor-package.mjs b/scripts/smoke-cursor-package.mjs new file mode 100644 index 0000000..ec2943f --- /dev/null +++ b/scripts/smoke-cursor-package.mjs @@ -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 }); }