diff --git a/AGENTS.md b/AGENTS.md index 775fe4f6..07925cd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This is an npm workspace: shared code lives in `packages/` (`shared`, `core`, `ui`, `mcp-app`, `skills`, `native`, `runtimes`) and each shipped product in `apps/` (`codex-plugin`, -`claude`, `mac`). Keep host chrome out of `packages/ui`: chrome every MCP Apps +`claude`, `cursor`, `mac`). Keep host chrome out of `packages/ui`: chrome every MCP Apps host shares goes in `packages/mcp-app`, chrome for one host in its app. Drive host differences from advertised capabilities rather than host names. See [CONTRIBUTING.md](CONTRIBUTING.md#repository-layout). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a6b9335..524d205f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,7 @@ packages know no host by name. | `packages/skills` | Agent skills for the shared tools; each host's package step includes the ones its host supports. | | `apps/codex-plugin` | Codex: manifest, launcher, assets, and its own server (`server/`, the self-updater) and panel (`ui/`, the update banner). | | `apps/claude` | Claude: the Claude Desktop extension (`manifest.json`, MCPB) and the Claude Code plugin (`.claude-plugin/plugin.json`). Both run the shared server and panel unchanged. | +| `apps/cursor` | Cursor: the Cursor plugin manifest (`.cursor-plugin/plugin.json`). It runs the shared server and panel unchanged. | | `apps/mac` | The Mac app: a SwiftUI shell (`Sources/`), the page it hosts (`web/`) and its server (`server/`). | | `packages/test-support` | Fixtures shared by tests across packages and apps. | | `tooling` | Shared build, native, release and device smoke-test scripts. Run them from the repository root. | @@ -306,6 +307,42 @@ 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` from the shared +runtime. It uses `apps/cursor/.cursor-plugin/plugin.json`, which starts +`${CURSOR_PLUGIN_ROOT}/dist/server.mjs` with Node.js from `PATH`, and leaves out the +Codex manifest, `.mcp.json`, launcher and updater. +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 ec2b9308..7be64d73 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,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). + ### Mac app The Mac app shows the devices and tools in their own window, without a chat. Pick @@ -112,8 +130,8 @@ See [storage privacy and review checks](docs/storage-privacy.md). Mobile Dev runs on an Apple Silicon Mac. In Codex desktop, the prebuilt plugin uses Codex's bundled Node runtime from its workspace dependency cache; the Claude -Desktop bundle uses Claude's Node runtime, and the Mac app bundles its own. Install -the tools for the platforms you use: +Desktop bundle uses Claude's Node runtime, the Cursor plugin uses Node.js from +`PATH`, and the Mac app bundles its own. Install the tools for the platforms you use: | Platform or feature | Requirements | | --- | --- | @@ -136,7 +154,8 @@ its device panel, inspection, logs, and performance tools. ## Get started Ask your agent to open the simulator beside this chat. In Codex, you can also -open Mobile Dev from the sidebar. Pick a device from the toolbar; selecting a stopped simulator or AVD +open Mobile Dev from the sidebar; 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. Click an iOS simulator or Android screen to focus it, then press **Command-V** to @@ -178,6 +197,7 @@ product in `apps/`: | `packages/native`, `packages/runtimes` | Native helpers and bundled Node runtimes | | `apps/codex-plugin` | The Codex plugin: manifest, updater, and update banner | | `apps/claude` | Claude: the Claude Desktop extension and Claude Code plugin manifests | +| `apps/cursor` | Cursor: the Cursor plugin manifest | | `apps/mac` | The Mac app: SwiftUI window and its page | To build a host, install a local package, or contribute changes, see diff --git a/apps/cursor/.cursor-plugin/plugin.json b/apps/cursor/.cursor-plugin/plugin.json new file mode 100644 index 00000000..3a61d1d2 --- /dev/null +++ b/apps/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "mobile-dev", + "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/mobile-dev", + "repository": "https://github.com/callstackincubator/mobile-dev", + "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/apps/cursor/assets/icon.svg b/apps/cursor/assets/icon.svg new file mode 100644 index 00000000..42a095c8 --- /dev/null +++ b/apps/cursor/assets/icon.svg @@ -0,0 +1,7 @@ + + ai-phone-01 + + + + + diff --git a/apps/cursor/package.json b/apps/cursor/package.json new file mode 100644 index 00000000..aec50c29 --- /dev/null +++ b/apps/cursor/package.json @@ -0,0 +1,7 @@ +{ + "name": "@mobile-dev/cursor", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Cursor: the Cursor plugin manifest for the shared server and MCP Apps panel." +} diff --git a/apps/cursor/tooling/config.mjs b/apps/cursor/tooling/config.mjs new file mode 100644 index 00000000..2785675e --- /dev/null +++ b/apps/cursor/tooling/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/apps/cursor/tooling/package.mjs b/apps/cursor/tooling/package.mjs new file mode 100644 index 00000000..48563bf1 --- /dev/null +++ b/apps/cursor/tooling/package.mjs @@ -0,0 +1,21 @@ +import { cp, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { telemetryBuildEnvironment } from "../../../tooling/telemetry-build.mjs"; +import { assertRuntime, releaseVersion, stageRuntime } from "../../../tooling/host-runtime.mjs"; + +// Cursor runs the shared server and MCP Apps panel unchanged: the host-neutral +// runtime plus its plugin manifest, started with Node from PATH, without Codex files. +const telemetryEnvironment = telemetryBuildEnvironment(); +await assertRuntime(telemetryEnvironment); +const version = await releaseVersion(); +const documents = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]; +const { name, ...fields } = JSON.parse(await readFile("apps/cursor/.cursor-plugin/plugin.json", "utf8")); +const plugin = resolve(`release/cursor-plugin/${name}`); +await rm(plugin, { recursive: true, force: true }); +await mkdir(`${plugin}/.cursor-plugin`, { recursive: true }); +await stageRuntime(`${plugin}/dist`); +await cp("apps/cursor/assets", `${plugin}/assets`, { recursive: true }); +for (const skill of ["mobile-dev", "mobile-dev-setup"]) await cp(`packages/skills/${skill}`, `${plugin}/skills/${skill}`, { recursive: true, verbatimSymlinks: true }); +for (const path of documents) await copyFile(path, `${plugin}/${path}`); +await writeFile(`${plugin}/.cursor-plugin/plugin.json`, JSON.stringify({ name, version, ...fields }, null, 2) + "\n"); +console.log(`Cursor plugin: ${plugin}`); diff --git a/apps/cursor/tooling/smoke.mjs b/apps/cursor/tooling/smoke.mjs new file mode 100644 index 00000000..f0536324 --- /dev/null +++ b/apps/cursor/tooling/smoke.mjs @@ -0,0 +1,67 @@ +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"; +import { releaseVersion } from "../../../tooling/host-runtime.mjs"; + +const version = await releaseVersion(); +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, 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)); + } + // Another host's files would load its launcher or manifest. + 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/); + // The Codex updater belongs to Codex's server; this plugin runs the shared one. + assert.equal(tools.some(tool => tool.name.includes("plugin_")), false); + console.log(`${environment}: Cursor plugin manifest, payload, MCP startup from the plugin root, chat fallback and no Codex 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 }); } diff --git a/docs/architecture.md b/docs/architecture.md index 1a57a94d..bab19770 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ The Agent Device MCP entry and skill are currently disabled; their implementatio ## Hosts The server (`packages/core`), the shared UI (`packages/ui`) and the MCP Apps panel -(`packages/mcp-app`) run in every host. Codex and Claude Desktop load the panel as an +(`packages/mcp-app`) run in every host. Codex, Claude and Cursor load the panel as an MCP App: the `App` from ext-apps carries tool calls and resource reads through the host, and the panel adapts to the capabilities each host advertises. The Mac app (`apps/mac`) starts the server over stdio and relays its JSON-RPC lines to a web view, @@ -17,7 +17,7 @@ A host adds to the shared pieces without changing them: - **Server:** each host's entry calls `runStdioServer` with its `ServerFeature`s. Codex adds the self-updater, which installs through Codex's marketplace; the Mac - app adds its page resource; Claude Desktop runs the shared server unchanged. + app adds its page resource; Claude and Cursor run the shared server unchanged. - **Panel:** a host's page entry calls `startMcpApp` with its chrome. Codex adds the update banner; Claude Desktop uses the shared entry. - **Views:** shared views depend only on `HostBridge`, the subset of `App` they use, diff --git a/docs/releases.md b/docs/releases.md index 77614a6d..62c13bf9 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -108,7 +108,8 @@ to identify the legacy Git marketplace for cleanup. GitHub tags and releases do not advertise store updates. Successful checks are cached for one hour across chats in the active Codex profile; unavailable checks retry after fifteen minutes. Old GitHub update caches are discarded. -Development builds do not check or migrate public installations. +Development builds do not check or migrate public installations. The Claude and +Cursor packages run the shared server, which has no updater. The app-server plugin API is under development. This integration deliberately accepts its compatibility risk. If discovery fails or its response no longer diff --git a/docs/telemetry.md b/docs/telemetry.md index 1be76a71..3aa88132 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`). Before the Cursor plugin, + Cursor sessions were reported as `other`. 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/package-lock.json b/package-lock.json index 496cbf4f..12cf17e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "packages/test-support", "apps/codex-plugin", "apps/claude", + "apps/cursor", "apps/mac" ], "dependencies": { @@ -81,6 +82,10 @@ "name": "@mobile-dev/codex-plugin", "version": "0.0.0" }, + "apps/cursor": { + "name": "@mobile-dev/cursor", + "version": "0.0.0" + }, "apps/mac": { "name": "@mobile-dev/mac", "version": "0.0.0" @@ -1995,6 +2000,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@mobile-dev/cursor": { + "resolved": "apps/cursor", + "link": true + }, "node_modules/@mobile-dev/mac": { "resolved": "apps/mac", "link": true diff --git a/package.json b/package.json index 180a1d28..377b066a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "packages/test-support", "apps/codex-plugin", "apps/claude", + "apps/cursor", "apps/mac" ], "type": "module", @@ -49,6 +50,9 @@ "claude:config": "node apps/claude/tooling/config.mjs", "package:claude": "node apps/claude/tooling/package.mjs", "test:claude": "node apps/claude/tooling/smoke.mjs", + "cursor:config": "node apps/cursor/tooling/config.mjs", + "package:cursor": "node apps/cursor/tooling/package.mjs", + "test:cursor": "node apps/cursor/tooling/smoke.mjs", "vendor:serve-emu": "node tooling/vendor-serve-emu.mjs", "dev": "node apps/codex-plugin/tooling/dev-ui.mjs", "dev:off": "node apps/codex-plugin/tooling/dev-ui.mjs --off", 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); } }