Skip to content
Closed
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
65 changes: 65 additions & 0 deletions docs/cursor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Cursor development preview

Runs the shared Mobile Dev MCP server and native helpers on an Apple Silicon
Mac. Requires Node >=22.19, Xcode for iOS and the Android SDK for Android.
Cursor 2.6 introduced MCP Apps; use a current desktop version with Apps enabled.
This preview has automated protocol/package coverage. Actual Cursor rendering,
stream throughput and device interaction still require the checks below.

## Run from source

Complete the normal repository setup, then run:

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

Merge the generated `.local-dev/cursor-mcp.json` server entry into
`~/.cursor/mcp.json` or your app project's `.cursor/mcp.json`. Preserve other
servers. The generator records absolute Node and server paths, so it does not
depend on Cursor's working directory or shell PATH. Restart the server in Cursor
after rebuilding. Regenerate configuration after moving the checkout or Node.

Ask: **Open Mobile Dev and list available devices without booting anything.**
Choose the target in chat or in the device picker. Expand is available only when
the host advertises fullscreen. Codex update checks and installation are disabled.

## Relocatable bundle

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

Unzip `release/mobile-dev-<version>-cursor-darwin-arm64.zip` into a persistent
directory. Run `node configure.mjs` there and merge the printed configuration into
Cursor. It uses the Node executable running that command; no Codex installation
is required. Re-run configuration if the directory or Node moves. Replace the
bundle and restart its server to update.

The bundle also contains a Cursor plugin manifest and `mcp.json` using
`${CURSOR_PLUGIN_ROOT}`. That plugin route requires Node on Cursor's PATH.
Do not enable both installation routes. Marketplace submission is separate from
creating this archive; no marketplace publication is implied.

Local builds retain development telemetry. Public packaging requires
`npm run build:release` followed by `npm run package:cursor -- --release`, plus
the normal source-map and native-symbol upload workflow.

## Validation before declaring support

- Open an existing iOS simulator, stream for five minutes and verify tap/drag.
- Attach a log, screenshot and annotation; verify exactly what reaches chat.
- Record CPU/memory and ask about a selected range, checking the reference.
- Hide/reopen the card, switch chats and disconnect/reconnect the device.
- Test Android H.264 and physical-iOS HEVC separately from simulator JPEG.
- Check local-server policy permits required native helpers, ADB and local
sockets. The server must run on the Mac with the devices, not a cloud agent.

The smoke test extracts into a path with spaces and verifies startup, UI metadata,
configuration relocation, telemetry opt-out and disabled Codex updates without
booting devices. Existing streaming, input and context telemetry is preserved.

Sources: [Cursor MCP](https://cursor.com/docs/mcp),
[Cursor plugin reference](https://cursor.com/docs/reference/plugins).
10 changes: 10 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

[Back to README](../README.md) · [Contributing](../CONTRIBUTING.md)

## Standard MCP Apps hosts

Explicit host profiles preserve the existing UI/server/native initialization,
release environment, identity, sampling and scrubbing. Standard context and chat
handoffs retain their existing timing and handled-error boundaries. Workspace
display-mode changes use `ui.workspace.display_mode_changed`; resize failures use
`workspace.resize`. Surface/device attribution and hidden-panel cleanup remain
on the active rendering and streaming paths. The Codex updater and its polling
are disabled outside Codex. No native telemetry changes are required.

## Volume policy

Since 0.1.164, application metrics in the UI, Node servers and native helpers use
Expand Down
8 changes: 8 additions & 0 deletions integrations/cursor/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "mobile-dev",
"version": "0.1.164",
"description": "Interactive local iOS and Android devices, logs and performance tools in Cursor.",
"author": { "name": "Callstack", "email": "hello@callstack.com" },
"homepage": "https://github.com/callstackincubator/codex-mobile-dev-plugin",
"mcpServers": "./mcp.json"
}
10 changes: 10 additions & 0 deletions integrations/cursor/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"mcpServers": {
"mobile-dev": {
"type": "stdio",
"command": "node",
"args": ["${CURSOR_PLUGIN_ROOT}/dist/server.mjs"],
"env": { "MOBILE_DEV_HOST": "cursor" }
}
}
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"test:ios-cpu": "node scripts/smoke-ios-cpu.mjs",
"package": "node scripts/package.mjs",
"package:release": "node scripts/package.mjs --release",
"cursor:config": "node scripts/cursor-config.mjs",
"package:cursor": "node scripts/package-cursor.mjs",
"test:cursor": "node scripts/smoke-editor-package.mjs cursor",
"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
9 changes: 9 additions & 0 deletions scripts/cursor-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { localHostConfig } from "./local-host-config.mjs";

const config = await localHostConfig("cursor");
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 mobile-dev entry from ${path} into ~/.cursor/mcp.json or your project's .cursor/mcp.json, preserving existing servers.`);
12 changes: 12 additions & 0 deletions scripts/local-host-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { access } from "node:fs/promises";
import { resolve } from "node:path";

// Generate data only. The caller decides where to save it; user settings are
// never overwritten. Resolve paths now so GUI hosts need no shell PATH or cwd.
export async function localHostConfig(host, root = ".", node = process.execPath) {
if (host !== "cursor" && host !== "vscode") throw new Error("Unsupported editor host.");
const server = resolve(root, "dist/server.mjs");
await access(server);
const entry = { type: "stdio", command: node, args: [server], env: { MOBILE_DEV_HOST: host } };
return host === "vscode" ? { servers: { "mobile-dev": entry } } : { mcpServers: { "mobile-dev": entry } };
}
2 changes: 2 additions & 0 deletions scripts/package-cursor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { packageEditor } from "./package-editor.mjs";
await packageEditor("cursor");
34 changes: 34 additions & 0 deletions scripts/package-editor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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";

export async function packageEditor(host) {
if (host !== "cursor" && host !== "vscode") throw new Error("Unsupported editor host.");
telemetryBuildEnvironment();
// Retain the canonical native inventory, licenses and environment checks.
execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" });
const { version } = JSON.parse(await readFile("package.json", "utf8"));
const source = resolve("release/marketplace/plugins/mobile-dev");
const directory = resolve(`release/${host}/mobile-dev`);
await rm(directory, { recursive: true, force: true });
await mkdir(directory, { recursive: true });
for (const path of ["dist", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]) {
await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true });
}
await cp(`integrations/${host}`, directory, { recursive: true });
await cp(`docs/${host}.md`, `${directory}/README.md`);
await cp("scripts/local-host-config.mjs", `${directory}/local-host-config.mjs`);
await writeFile(`${directory}/configure.mjs`, `import { localHostConfig } from "./local-host-config.mjs";\nconsole.log(JSON.stringify(await localHostConfig(${JSON.stringify(host)}, import.meta.dirname), null, 2));\n`);
if (host === "cursor") {
const path = `${directory}/.cursor-plugin/plugin.json`;
const manifest = JSON.parse(await readFile(path, "utf8"));
manifest.version = version;
await writeFile(path, JSON.stringify(manifest, null, 2) + "\n");
}
const archive = resolve(`release/mobile-dev-${version}-${host}-darwin-arm64.zip`);
await rm(archive, { force: true });
execFileSync("zip", ["-qr", archive, "."], { cwd: directory });
execFileSync("unzip", ["-tq", archive], { stdio: "inherit" });
console.log(`${host} bundle: ${archive}`);
}
60 changes: 60 additions & 0 deletions scripts/smoke-editor-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { access, mkdir, mkdtemp, readFile, realpath, 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 host = process.argv[2];
if (host !== "cursor" && host !== "vscode") throw new Error("Pass cursor or vscode.");
const { version } = JSON.parse(await readFile("package.json", "utf8"));
const archive = resolve(process.argv[3] ?? `release/mobile-dev-${version}-${host}-darwin-arm64.zip`);
const temporary = await realpath(await mkdtemp(join(tmpdir(), `mobile-dev-${host}-`)));
try {
const directory = join(temporary, "bundle with spaces");
await mkdir(directory);
execFileSync("unzip", ["-q", archive, "-d", directory]);
const config = JSON.parse(execFileSync(process.execPath, [join(directory, "configure.mjs")], { cwd: temporary, encoding: "utf8" }));
const entry = config[host === "cursor" ? "mcpServers" : "servers"]["mobile-dev"];
assert.equal(entry.command, process.execPath);
assert.equal(entry.env.MOBILE_DEV_HOST, host);
assert.equal(entry.args[0], join(directory, "dist/server.mjs"));
if (host === "cursor") {
const manifest = JSON.parse(await readFile(join(directory, ".cursor-plugin/plugin.json"), "utf8"));
assert.equal(manifest.version, version);
const pluginConfig = JSON.parse(await readFile(join(directory, "mcp.json"), "utf8"));
assert.equal(pluginConfig.mcpServers["mobile-dev"].env.MOBILE_DEV_HOST, host);
assert.equal(pluginConfig.mcpServers["mobile-dev"].args[0].replaceAll("${CURSOR_PLUGIN_ROOT}", directory), entry.args[0]);
}
for (const path of ["dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node", "dist/serve-emu/src/cli.mjs"]) await access(join(directory, path));
const home = join(temporary, "home");
await mkdir(home);
for (const environment of ["development", "release"]) {
const client = new Client({ name: `${host}-package-smoke`, version: "1" });
const transport = new StdioClientTransport({
command: entry.command, args: entry.args, cwd: temporary, stderr: "pipe",
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, ...entry.env, MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: environment },
});
let diagnostics = "";
transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-4000); });
try {
await client.connect(transport);
const { tools } = await client.listTools();
assert.ok(!tools.some(tool => tool.name === "mobile_choose_devices"));
const workspace = tools.find(tool => tool.name === "mobile_open_workspace");
assert.ok(workspace);
const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri });
const html = resource.contents[0].text;
assert.ok(html.includes(`data-host="${host}"`));
assert.ok(html.includes(`name="mobile-dev-environment" content="${environment}"`));
assert.ok(html.includes('name="mobile-dev-telemetry" content="off"'));
const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} });
assert.equal(check.structuredContent.update.status, "disabled");
const install = await client.callTool({ name: "mobile_install_plugin_update", arguments: {} });
assert.equal(install.isError, true);
console.log(`${host}/${environment}: relocated startup, UI resource, telemetry opt-out and updater isolation 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
Loading