From d61afb0e3b95034da0f379ac4e846b69678f28c5 Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Sat, 24 Oct 2026 13:02:18 +0400 Subject: [PATCH 1/2] 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 00000000..cc18a97e --- /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 a2389b42..0e576e32 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 1e130e83..c129e137 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 b1b6939e..9b7a4808 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 351c1db0..e78ad1a1 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 00000000..2785675e --- /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 00000000..1c956210 --- /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 00000000..ec2943f9 --- /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 }); } From 3b55590019efab1ca03753b3658cacd2745add44 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Tue, 27 Oct 2026 16:06:57 +0100 Subject: [PATCH 2/2] feat(telemetry): attribute cursor as its own host --- docs/telemetry.md | 9 +++++---- packages/shared/src/telemetry.ts | 3 ++- packages/ui/test/ui-telemetry.test.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index 88fbf183..62ddc512 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -5,18 +5,19 @@ ## Hosts Since 0.1.165, UI and server telemetry carry a `host` tag and attribute with one of -`codex`, `claude`, `mac` or `other`. Codex, Claude Desktop and other MCP Apps hosts -load the same panel and server; the Mac app runs the same server and shared UI. +`codex`, `claude`, `cursor`, `mac` or `other`. Codex, Claude, Cursor and other MCP +Apps hosts load the same panel and server; the Mac app runs the same server and shared UI. Behavior never depends on this value; it only attributes measurements and errors. - The Mac page names itself (`data-host="mac"`); the Mac app also starts the server with `MOBILE_DEV_HOST=mac`. - MCP Apps panels are labelled once connected: `codex` when the host advertises OpenAI extensions, otherwise the host's reported name mapped by `telemetryHost` - (`claude` for Claude, else `other`). Calls before connecting carry no host. + (`claude` for Claude, `cursor` for Cursor, else `other`). Cursor is labelled since + its plugin was added. Calls before connecting carry no host. - The server labels itself from the connecting MCP client's name on initialize and reports `other` until then. -- The server's metadata allowlist accepts only these four values; the raw host or +- The server's metadata allowlist accepts only these values; the raw host or client name is never sent. Earlier releases have no `host` value. Surfaces, measurement names, units and diff --git a/packages/shared/src/telemetry.ts b/packages/shared/src/telemetry.ts index 73b02533..ea69f573 100644 --- a/packages/shared/src/telemetry.ts +++ b/packages/shared/src/telemetry.ts @@ -10,7 +10,7 @@ export const SENTRY_ORIGIN = "https://o4512180958068736.ingest.de.sentry.io"; export const SENTRY_RELEASE = `mobile-dev@${PLUGIN_VERSION}`; export const TELEMETRY_META_KEY = "mobile-dev/telemetry"; export const TELEMETRY_INTERVAL_MS = 30_000; -export const TELEMETRY_HOSTS = ["codex", "claude", "mac", "other"] as const; +export const TELEMETRY_HOSTS = ["codex", "claude", "cursor", "mac", "other"] as const; export type TelemetryHost = typeof TELEMETRY_HOSTS[number]; /** @@ -21,6 +21,7 @@ export function telemetryHost(name: string | undefined): TelemetryHost { if (name === undefined) return "other"; if (/codex|openai/i.test(name)) return "codex"; if (/claude/i.test(name)) return "claude"; + if (/cursor/i.test(name)) return "cursor"; return "other"; } export const METRIC_SAMPLE_RATE = 0.005; diff --git a/packages/ui/test/ui-telemetry.test.ts b/packages/ui/test/ui-telemetry.test.ts index c6fe8589..18b8bd40 100644 --- a/packages/ui/test/ui-telemetry.test.ts +++ b/packages/ui/test/ui-telemetry.test.ts @@ -713,7 +713,7 @@ test("UI telemetry attributes each call to the host that renders the page", asyn assert.equal(calls[0]._meta?.["mobile-dev/telemetry"].host, expected); await api.stopUiTelemetry(); if (attribute === "") { - for (const [name, label] of [["Codex Desktop", "codex"], ["openai-codex", "codex"], ["Claude", "claude"], ["claude-ai", "claude"], ["PRIVATE_HOST", "other"], [undefined, "other"]] as const) { + for (const [name, label] of [["Codex Desktop", "codex"], ["openai-codex", "codex"], ["Claude", "claude"], ["claude-ai", "claude"], ["cursor-vscode", "cursor"], ["PRIVATE_HOST", "other"], [undefined, "other"]] as const) { assert.equal(api.telemetryHost(name), label); } }