From a6aae703a711ef680e02d6d65cf83f50e022b455 Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 15:16:11 +0400
Subject: [PATCH 1/2] refactor(hosts): share standard mcp app integration
---
docs/telemetry.md | 10 ++++
scripts/local-host-config.mjs | 12 +++++
scripts/package-editor.mjs | 34 ++++++++++++
scripts/smoke-editor-package.mjs | 60 +++++++++++++++++++++
src/server/plugin.ts | 28 +++++++---
src/shared/host.ts | 6 +++
src/ui/app.tsx | 1 -
src/ui/chat-message.ts | 11 ++++
src/ui/comparison-controller.ts | 4 +-
src/ui/components/context-attachments.tsx | 20 +++++++
src/ui/components/display-mode-button.tsx | 20 +++++++
src/ui/components/workspace.tsx | 7 ++-
src/ui/model-context.ts | 60 ++++++++++++++++-----
src/ui/recording-controller.ts | 7 +--
src/ui/theme.css | 31 ++++++++++-
src/ui/tracesift-controller.ts | 10 ++--
src/ui/workspace-app.tsx | 33 +++++++++---
tests/chat-message.test.ts | 13 +++++
tests/host-profile.test.ts | 41 +++++++++++++++
tests/local-host-config.test.ts | 23 +++++++++
tests/log-context.test.ts | 63 +++++++++++++++++++++--
tests/recording-context.test.ts | 2 +-
tests/screen-annotations.test.ts | 21 +++++++-
tests/tracesift-ui.test.ts | 6 +--
tests/workspace-opening.test.ts | 13 ++++-
25 files changed, 485 insertions(+), 51 deletions(-)
create mode 100644 scripts/local-host-config.mjs
create mode 100644 scripts/package-editor.mjs
create mode 100644 scripts/smoke-editor-package.mjs
create mode 100644 src/shared/host.ts
create mode 100644 src/ui/chat-message.ts
create mode 100644 src/ui/components/context-attachments.tsx
create mode 100644 src/ui/components/display-mode-button.tsx
create mode 100644 tests/chat-message.test.ts
create mode 100644 tests/host-profile.test.ts
create mode 100644 tests/local-host-config.test.ts
diff --git a/docs/telemetry.md b/docs/telemetry.md
index 6cbdbc39..56265596 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -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
diff --git a/scripts/local-host-config.mjs b/scripts/local-host-config.mjs
new file mode 100644
index 00000000..9526910c
--- /dev/null
+++ b/scripts/local-host-config.mjs
@@ -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 } };
+}
diff --git a/scripts/package-editor.mjs b/scripts/package-editor.mjs
new file mode 100644
index 00000000..73fa8fc4
--- /dev/null
+++ b/scripts/package-editor.mjs
@@ -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} development bundle: ${archive}`);
+}
diff --git a/scripts/smoke-editor-package.mjs b/scripts/smoke-editor-package.mjs
new file mode 100644
index 00000000..d1e50dc4
--- /dev/null
+++ b/scripts/smoke-editor-package.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { access, 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 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 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 }); }
diff --git a/src/server/plugin.ts b/src/server/plugin.ts
index d592bdc8..7bb89881 100644
--- a/src/server/plugin.ts
+++ b/src/server/plugin.ts
@@ -1,3 +1,4 @@
+import { configuredHost } from "../shared/host.ts";
import { parseResourceInput } from "./resource-input.ts";
import { openRequestSession } from "./request-session.ts";
import type { ServerRequestContext } from "./request-session.ts";
@@ -90,22 +91,28 @@ function guarded(handler: (input: T, context: ServerRequestContext) => Promis
}
export async function createPlugin(html: string | (() => Promise), baguette = new Baguette(), simulatorInput: SimulatorInput = new SimulatorInputService(udid => baguette.repairInput(udid)), logs = new LogSessions(), android = new ServeEmu(), copyScreenshot = copyPNGToClipboard, providedCpu?: CpuSessions) {
+ const host = configuredHost(process.env.MOBILE_DEV_HOST);
+ const standardHost = host !== "codex";
const streams = new StreamSessions(baguette);
let selectedCpu = providedCpu;
if (selectedCpu === undefined) selectedCpu = await createCpuSessions();
const cpu = selectedCpu;
const fps = new DisplayFpsSessions();
const server = new McpServer({ name: "mobile-dev", version: PLUGIN_VERSION }, {
- instructions: "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.",
+ instructions: standardHost
+ ? "For mobile app development, call mobile_open_workspace to show Mobile Dev in the conversation. Use the display modes supported by the host. Use selected device IDs. If device targets are ambiguous, discover candidates and ask the user in chat; wait for their answer before starting the task. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate."
+ : "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.",
});
instrumentMcpServer(server);
- const updates = new PluginUpdates();
+ const updates = new PluginUpdates(standardHost ? { enabled: false } : {});
addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS });
registerPluginUpdateTools(server, updates);
- const extensions = new OpenAIExtensions(server);
- registerDeviceChoiceTools(server, extensions, {
- simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices,
- });
+ if (!standardHost) {
+ const extensions = new OpenAIExtensions(server);
+ registerDeviceChoiceTools(server, extensions, {
+ simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices,
+ });
+ }
registerIosDeviceTools(server);
const closeIosMirror = registerIosMirrorTools(server, APP_URI);
registerLogTools(server, logs, baguette);
@@ -159,6 +166,7 @@ export async function createPlugin(html: string | (() => Promise), b
}
function configureUI(content: string): string {
+ if (standardHost) content = content.replace(" Promise), b
registerAppTool(server, "mobile_open_workspace", {
title: "Mobile Dev",
- description: "Open the fullscreen Mobile Dev workspace when the user asks for fullscreen, with logs and performance tools beside the devices. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development beside a chat, prefer mobile_open_simulator. Starts the bundled backend without booting a device.",
+ description: standardHost
+ ? "Open Mobile Dev in the conversation, with devices, logs and performance tools. Expand when supported by the host. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting a device."
+ : "Open the fullscreen Mobile Dev workspace when the user asks for fullscreen, with logs and performance tools beside the devices. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development beside a chat, prefer mobile_open_simulator. Starts the bundled backend without booting a device.",
inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema }, annotations: write,
_meta: {
ui: { resourceUri: WORKSPACE_URI, visibility: ["app", "model"] },
@@ -259,7 +269,9 @@ export async function createPlugin(html: string | (() => Promise), b
registerAppTool(server, "mobile_open_simulator", {
title: "Mobile Dev",
- description: "Open Mobile Dev beside the chat for local mobile app development, before the first device launch unless a panel is open or the workflow is tool-only. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices.",
+ description: standardHost
+ ? "Open a compact Mobile Dev view in the conversation. Prefer mobile_open_workspace for devices with logs and performance tools. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices."
+ : "Open Mobile Dev beside the chat for local mobile app development, before the first device launch unless a panel is open or the workflow is tool-only. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices.",
inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema }, annotations: write,
_meta: {
ui: { resourceUri: APP_URI, visibility: ["app", "model"] },
diff --git a/src/shared/host.ts b/src/shared/host.ts
new file mode 100644
index 00000000..0cfc525c
--- /dev/null
+++ b/src/shared/host.ts
@@ -0,0 +1,6 @@
+// Only explicitly configured distributions opt out of Codex behavior.
+export function configuredHost(value: string | undefined): "codex" | "claude-desktop" | "cursor" | "vscode" {
+ if (value === undefined || value === "codex") return "codex";
+ if (value === "claude-desktop" || value === "cursor" || value === "vscode") return value;
+ throw new Error("Unsupported Mobile Dev host.");
+}
diff --git a/src/ui/app.tsx b/src/ui/app.tsx
index 4e637831..607f8da9 100644
--- a/src/ui/app.tsx
+++ b/src/ui/app.tsx
@@ -1,4 +1,3 @@
-import "@openai/mcp-extensions/app/styles.css";
import "@fontsource-variable/inter";
import "./theme.css";
import "./style.css";
diff --git a/src/ui/chat-message.ts b/src/ui/chat-message.ts
new file mode 100644
index 00000000..e2c575ad
--- /dev/null
+++ b/src/ui/chat-message.ts
@@ -0,0 +1,11 @@
+import type { App } from "@modelcontextprotocol/ext-apps";
+
+// Standard hosts receive a self-contained user message. Codex can additionally
+// route it to the active composer when it advertises that extension.
+export function chatMessage(app: Pick, text: string): Parameters[0] {
+ const routed = app.getHostCapabilities()?.experimental?.["openai/message"] != null;
+ return {
+ role: "user", content: [{ type: "text", text }],
+ ...(routed ? { _meta: { "openai/message": { target: "active", send: true } } } : {}),
+ };
+}
diff --git a/src/ui/comparison-controller.ts b/src/ui/comparison-controller.ts
index 9f86bd5d..971c08c1 100644
--- a/src/ui/comparison-controller.ts
+++ b/src/ui/comparison-controller.ts
@@ -1,6 +1,6 @@
+import { chatMessage } from "./chat-message.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import type { OpenAIMessageParams } from "@openai/mcp-extensions/app";
import { comparisonDuration, comparisonSchema } from "../shared/performance-comparison.ts";
import type { PerformanceComparison } from "../shared/performance-comparison.ts";
import { recordingRangeSchema } from "../shared/recordings.ts";
@@ -100,7 +100,7 @@ export class ComparisonController {
const reference = JSON.stringify({ recordingIds, title: comparison.title, ...(range ? { range } : {}) });
const interval = range ? `${range.start}–${range.end}s` : "the entire runs";
const prompt = `Compare CPU, memory, and device-wide Display FPS for ${interval}, aligned at recording start. Read the original runs and summaries with mobile_compare_performance_recordings using ${reference}. Account for different durations, devices, apps and memory definitions. Distinguish measurements from hypotheses about their cause; device-wide display data cannot attribute a slowdown to one app.`;
- const message: OpenAIMessageParams = { role: "user", content: [{ type: "text", text: prompt }], _meta: { "openai/message": { target: "active", send: true } } };
+ const message = chatMessage(this.app, prompt);
const startedAt = performance.now();
try {
const result = await this.app.sendMessage(message);
diff --git a/src/ui/components/context-attachments.tsx b/src/ui/components/context-attachments.tsx
new file mode 100644
index 00000000..d848e8d1
--- /dev/null
+++ b/src/ui/components/context-attachments.tsx
@@ -0,0 +1,20 @@
+import { useState, useSyncExternalStore } from "react";
+import type { PanelContext } from "../model-context.ts";
+import { Button } from "./ui/button";
+import { captureUiError } from "../telemetry.ts";
+
+export function ContextAttachments({ context }: { context: PanelContext }) {
+ const count = useSyncExternalStore(context.subscribe, () => context.attachmentCount);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(false);
+ if (context.usesHostAttachments || !count) return null;
+ return
+
+ {error && Could not clear attachments. Try again.}
+
;
+}
diff --git a/src/ui/components/display-mode-button.tsx b/src/ui/components/display-mode-button.tsx
new file mode 100644
index 00000000..ff9b9ad8
--- /dev/null
+++ b/src/ui/components/display-mode-button.tsx
@@ -0,0 +1,20 @@
+import type { App } from "@modelcontextprotocol/ext-apps";
+import { useState } from "react";
+import { Button } from "./ui/button";
+import { captureUiError, countUiEvent } from "../telemetry.ts";
+
+export function DisplayModeButton({ app }: { app: App }) {
+ const host = app.getHostContext();
+ const [busy, setBusy] = useState(false);
+ const mode = host?.displayMode === "fullscreen" ? "inline" : "fullscreen";
+ if (!host?.availableDisplayModes?.includes(mode)) return null;
+ return ;
+}
diff --git a/src/ui/components/workspace.tsx b/src/ui/components/workspace.tsx
index a5c6262d..e361ffdb 100644
--- a/src/ui/components/workspace.tsx
+++ b/src/ui/components/workspace.tsx
@@ -1,5 +1,6 @@
import { ActivityIcon, DatabaseIcon, TerminalIcon, PanelsTopLeftIcon } from "lucide-react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import type { ReactNode } from "react";
import type { LogsPanel } from "../logs-panel.ts";
import type { PerformancePanel } from "../performance-panel.ts";
import type { StoragePanel } from "../storage-panel.ts";
@@ -31,7 +32,7 @@ const tools = {
type Tool = keyof typeof tools;
const isTool = (value: string): value is Tool => value in tools;
-export function Workspace({ logs, performance, storage, recordingController, updates, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) {
+export function Workspace({ logs, performance, storage, recordingController, updates, displayModeControl, contextControl, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; displayModeControl?: ReactNode; contextControl?: ReactNode; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) {
const [tool, setTool] = useState("logs");
const [savedVisible, setSavedVisible] = useState(false);
const [simulatorsCollapsed, setSimulatorsCollapsed] = useState(false);
@@ -106,6 +107,7 @@ export function Workspace({ logs, performance, storage, recordingController, upd
const menuSide = bottomToolbar ? "top" : "bottom";
const ToolIcon = tools[tool].icon;
const toolbar =
}>
-
+ : undefined} contextControl={} automaticLayout={automaticLayout} onLayout={changeLayout} />
;
reactRoot.render(workspace);
}
@@ -91,6 +94,7 @@ let disposingUI: Promise | undefined;
function disposeUI() {
return disposingUI ??= (async () => {
window.removeEventListener("pagehide", onPageHide);
+ app.removeEventListener("hostcontextchanged", hostContext);
window.removeEventListener("focus", resumeContext);
window.removeEventListener("pageshow", resumeContext);
document.removeEventListener("visibilitychange", resumeContext);
@@ -108,15 +112,32 @@ function onPageHide(event: PageTransitionEvent) { if (!event.persisted) { stopLi
window.addEventListener("pagehide", onPageHide);
app.onteardown = async () => { stopLiveReload(); await disposeUI(); return {}; };
+let reportedInlineHeight: number | undefined;
function hostContext() {
+ if (disposingUI) return;
const host = app.getHostContext();
if (host?.theme) applyDocumentTheme(host.theme);
if (host?.styles?.variables) applyHostStyleVariables(host.styles.variables);
- document.documentElement.style.setProperty("--font-sans", '"Inter Variable", sans-serif');
panelContext.hostChanged();
recordingController.hostChanged();
panelContext.resume();
for (const panel of panels) { panel.fitScreen(); panel.controls(); }
+ // Initial context arrives with connect(), without a hostcontextchanged event.
+ if (standardHost) {
+ const root = document.documentElement;
+ const fullscreen = host?.displayMode === "fullscreen";
+ const dimensions = host?.containerDimensions;
+ const maximum = dimensions && "maxHeight" in dimensions ? dimensions.maxHeight : undefined;
+ const height = typeof maximum === "number" && maximum > 0 ? Math.min(600, maximum) : 600;
+ root.style.setProperty("--workspace-height", fullscreen ? "100dvh" : `${height}px`);
+ root.style.setProperty("--workspace-bottom-inset", `${host?.safeAreaInsets?.bottom ?? 0}px`);
+ if (fullscreen) reportedInlineHeight = undefined;
+ else if (reportedInlineHeight !== height) {
+ reportedInlineHeight = height;
+ void app.sendSizeChanged({ height }).catch(error => captureUiError(error, "workspace.resize"));
+ }
+ renderWorkspace();
+ }
}
function resumeContext() {
if (document.visibilityState !== "hidden" && !disposingUI) {
@@ -129,7 +150,7 @@ window.addEventListener("pageshow", resumeContext);
document.addEventListener("visibilitychange", resumeContext);
function connectionFailed(error: unknown) {
captureUiError(error, "host.connect");
- const message = error instanceof Error ? error.message : "Could not connect to Codex.";
+ const message = error instanceof Error ? error.message : "Could not connect to the host.";
for (const panel of panels) panel.notice(message);
}
function openDevices(input: unknown) {
@@ -169,7 +190,7 @@ void (async () => {
performancePanel.setAvailable(available);
storagePanel.setAvailable(available);
deviceApps.setAvailable(available);
- updates.setAvailable(available);
+ updates.setAvailable(available && !standardHost);
for (const panel of panels) if (!available) panel.notice("This host cannot call the plugin's simulator tools.");
if (available) {
stopLiveReload = startLiveReload(app, disposeUI);
diff --git a/tests/chat-message.test.ts b/tests/chat-message.test.ts
new file mode 100644
index 00000000..d070a4db
--- /dev/null
+++ b/tests/chat-message.test.ts
@@ -0,0 +1,13 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { chatMessage } from "../src/ui/chat-message.ts";
+
+test("standard hosts receive a complete message without OpenAI routing", () => {
+ const message = chatMessage({ getHostCapabilities: () => ({ message: { text: {} } }) }, "Explain this log: failed request");
+ assert.deepEqual(message, { role: "user", content: [{ type: "text", text: "Explain this log: failed request" }] });
+});
+
+test("advertised OpenAI routing is preserved", () => {
+ const message = chatMessage({ getHostCapabilities: () => ({ experimental: { "openai/message": {} } }) }, "Explain this log");
+ assert.deepEqual(message._meta, { "openai/message": { target: "active", send: true } });
+});
diff --git a/tests/host-profile.test.ts b/tests/host-profile.test.ts
new file mode 100644
index 00000000..4107d1ab
--- /dev/null
+++ b/tests/host-profile.test.ts
@@ -0,0 +1,41 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { configuredHost } from "../src/shared/host.ts";
+import { APP_URI } from "../src/server/plugin.ts";
+import { createTestPlugin } from "./fixtures.ts";
+
+test("unknown host configuration fails instead of enabling Codex features", () => {
+ assert.equal(configuredHost(undefined), "codex");
+ assert.throws(() => configuredHost("unknown"), /Unsupported/);
+});
+
+for (const host of ["cursor", "vscode", "claude-desktop"] as const) {
+ test(`${host} disables Codex updates and elicitation while preserving app resources`, async t => {
+ const previous = process.env.MOBILE_DEV_HOST;
+ process.env.MOBILE_DEV_HOST = host;
+ t.after(() => {
+ if (previous === undefined) delete process.env.MOBILE_DEV_HOST;
+ else process.env.MOBILE_DEV_HOST = previous;
+ });
+ const plugin = await createTestPlugin('');
+ const client = new Client({ name: "host-test", version: "1" });
+ t.after(async () => { await client.close(); await plugin.close(); });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await plugin.server.connect(serverTransport);
+ await client.connect(clientTransport);
+ const { tools } = await client.listTools();
+ assert.ok(tools.some(tool => tool.name === "mobile_open_workspace"));
+ assert.ok(!tools.some(tool => tool.name === "mobile_choose_devices"));
+ const update = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} });
+ assert.equal((update.structuredContent?.update as { status: string }).status, "disabled");
+ const install = await client.callTool({ name: "mobile_install_plugin_update", arguments: {} });
+ assert.equal(install.isError, true);
+ const resource = await client.readResource({ uri: APP_URI });
+ assert.match(resource.contents[0].text as string, new RegExp(`data-host="${host}"`));
+ assert.match(resource.contents[0].text as string, /mobile-dev-environment" content="development"/);
+ assert.ok(resource.contents[0]._meta?.ui);
+ assert.match(client.getInstructions() ?? "", /ask the user in chat/);
+ });
+}
diff --git a/tests/local-host-config.test.ts b/tests/local-host-config.test.ts
new file mode 100644
index 00000000..6b8f4024
--- /dev/null
+++ b/tests/local-host-config.test.ts
@@ -0,0 +1,23 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { localHostConfig } from "../scripts/local-host-config.mjs";
+
+test("editor configs relocate the bundle with spaces without assuming a shell or Codex runtime", async t => {
+ const root = await mkdtemp(join(tmpdir(), "mobile dev config "));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ await assert.rejects(localHostConfig("cursor", root), /ENOENT/);
+ await mkdir(join(root, "dist"));
+ await writeFile(join(root, "dist/server.mjs"), "");
+ for (const host of ["cursor", "vscode"]) {
+ const config = await localHostConfig(host, root, "/Node Runtime/bin/node");
+ const key = host === "vscode" ? "servers" : "mcpServers";
+ assert.deepEqual(Object.keys(config), [key]);
+ assert.deepEqual(config[key]["mobile-dev"], {
+ type: "stdio", command: "/Node Runtime/bin/node", args: [join(root, "dist/server.mjs")], env: { MOBILE_DEV_HOST: host },
+ });
+ }
+ await assert.rejects(localHostConfig("other", root), /Unsupported/);
+});
diff --git a/tests/log-context.test.ts b/tests/log-context.test.ts
index 423e2b75..19e0dc82 100644
--- a/tests/log-context.test.ts
+++ b/tests/log-context.test.ts
@@ -14,7 +14,7 @@ const log = stackLogs([{ timestamp: "2026-09-30T12:00:00Z", message: "Request fa
test("hosts without context pills receive the clicked log in the message", async () => {
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} } }),
async sendMessage(params: Parameters[0]) { messages.push(params); return {}; },
} as App, {} as OpenAIExtensions);
for (const level of ["error", "warn", "info", "debug"] as const) {
@@ -29,7 +29,7 @@ test("hosts without context pills receive the clicked log in the message", async
test("chat actions report host rejection and unsupported hosts", async () => {
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} } }),
async sendMessage() { return { isError: true }; },
} as unknown as App, {} as OpenAIExtensions);
await assert.rejects(context.sendLogToChat(log), /Could not send/);
@@ -53,7 +53,7 @@ function fixture(send: App["sendMessage"] = async () => ({})) {
};
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { image: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} }, updateModelContext: { image: {} } }),
async sendMessage(...args: Parameters) { messages.push(args[0]); return send(...args); },
} as App, { modelContext } as OpenAIExtensions);
return { context, updates, messages, clear() { current = null; context.hostChanged(); }, removeContent() { if (current) current = { ...current, content: [] }; context.hostChanged(); }, removeScreenshot(id: string) { if (current) current = { ...current, updateId: "removed", content: current.content?.filter(item => item._meta?.["mobile-dev/screenshotId"] !== id) }; context.hostChanged(); }, fail() { failure = true; }, hold(value: Promise) { gate = value; } };
@@ -338,3 +338,60 @@ test("side by side devices share both IDs while logs keep the active selection",
assert.equal(updates.at(-1)?.content?.some(item => item.type === "text" && item.text.includes("emulator-5554")), false);
assert.deepEqual(updates.at(-1)?.structuredContent?.selectedLog, log);
});
+
+test("standard context supports logs, local removal and complete chat messages", async () => {
+ const updates: Parameters[0][] = [];
+ const messages: Parameters[0][] = [];
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {}, structuredContent: {} } }),
+ async updateModelContext(params) { updates.push(params); return {}; },
+ async sendMessage(params) { messages.push(params); return {}; },
+ } as App, {} as OpenAIExtensions);
+ assert.equal(context.canAttach, true);
+ assert.equal(context.canAttachScreenshots, false);
+ await context.attach(log);
+ assert.equal(context.attachmentCount, 1);
+ assert.ok(updates.at(-1)?.content?.every(item => item._meta === undefined));
+ context.hostChanged();
+ assert.equal(context.attachmentCount, 1, "No host pills does not mean attachments were removed");
+ await context.sendLogToChat(log);
+ assert.match(JSON.stringify(messages[0].content), /Request failed/);
+ assert.equal(messages[0]._meta, undefined);
+ assert.equal(context.attachmentCount, 0);
+ assert.equal(updates.at(-1)?.structuredContent?.selectedLog, null);
+});
+
+test("standard screenshot context is capability-gated and clearing rolls back on failure", async () => {
+ let images = false;
+ let fail = false;
+ const updates: Parameters[0][] = [];
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ updateModelContext: { text: {}, ...(images ? { image: {} } : {}) } }),
+ async updateModelContext(params) { if (fail) throw new Error("Host unavailable"); updates.push(params); return {}; },
+ } as unknown as App, {} as OpenAIExtensions);
+ const screenshot = { id: "shot-standard", simulator, data: "cG5n", mimeType: "image/png" } as ScreenshotAttachment;
+ await assert.rejects(context.attachScreenshot(screenshot), /does not support/);
+ images = true;
+ await context.attachScreenshot(screenshot);
+ assert.equal(context.attachmentCount, 1);
+ assert.equal(updates.at(-1)?.structuredContent, undefined, "Do not send unsupported structured context");
+ assert.equal(updates.at(-1)?.content?.filter(item => item.type === "image").length, 1);
+ fail = true;
+ await assert.rejects(context.clearAttachments(), /Host unavailable/);
+ assert.equal(context.attachmentCount, 1);
+ fail = false;
+ await context.clearAttachments();
+ assert.equal(context.attachmentCount, 0);
+ assert.equal(updates.at(-1)?.content?.some(item => item.type === "image"), false);
+});
+
+test("failed standard context cleanup never retries a delivered message", async () => {
+ let sent = 0;
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {}, structuredContent: {} } }),
+ async updateModelContext() { if (sent) throw new Error("Cleanup unavailable"); return {}; },
+ async sendMessage() { sent++; return {}; },
+ } as unknown as App, {} as OpenAIExtensions);
+ await context.sendLogToChat(log);
+ assert.equal(sent, 1);
+});
diff --git a/tests/recording-context.test.ts b/tests/recording-context.test.ts
index 123683b2..7426717c 100644
--- a/tests/recording-context.test.ts
+++ b/tests/recording-context.test.ts
@@ -28,7 +28,7 @@ function fixture(t: TestContext, pills = true) {
},
};
const app = {
- getHostCapabilities: () => ({ message: { text: {} } }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {} } }),
async sendMessage(params: Parameters[0], options: { timeout?: number; maxTotalTimeout?: number }) {
assert.equal(options.timeout, 5000);
assert.equal(options.maxTotalTimeout, 5000);
diff --git a/tests/screen-annotations.test.ts b/tests/screen-annotations.test.ts
index 0ae435fc..613a5e95 100644
--- a/tests/screen-annotations.test.ts
+++ b/tests/screen-annotations.test.ts
@@ -28,7 +28,7 @@ function fixture(messageApp?: App) {
const updates: Parameters[0][] = [];
const messages: Parameters[0][] = [];
const context = new PanelContext({
- getHostCapabilities: () => ({ message: { text: {}, ...(imageMessages ? { image: {} } : {}) }, updateModelContext: imageAttachments ? { image: {} } : {} }),
+ getHostCapabilities: () => ({ experimental: { "openai/message": {} }, message: { text: {}, ...(imageMessages ? { image: {} } : {}) }, updateModelContext: imageAttachments ? { image: {} } : {} }),
async sendMessage(message: Parameters[0], options: Parameters[1]) { if (sendFailures-- > 0) throw new Error("MCP app messages require an available composer"); messages.push(message); return messageApp ? messageApp.sendMessage(message, options) : { isError: rejected }; },
} as unknown as App, { modelContext: {
getCurrent: () => current,
@@ -514,7 +514,7 @@ test("a second batch survives a missing composer and sends after a manual retry"
f.delayMessageComposer(Infinity); await f.store.send();
assert.equal(f.store.getSnapshot().busy, false);
assert.equal(f.store.getSnapshot().sending, false);
- assert.match(f.store.getSnapshot().sendError, /Codex could not find this chat's input/);
+ assert.match(f.store.getSnapshot().sendError, /The host could not find this chat's input/);
assert.equal(f.context.screenAnnotations.length, 1);
assert.match(f.store.messageText, /SECOND_BATCH/);
assert.doesNotMatch(f.store.messageText, /Make this button larger|data:image|AA==/);
@@ -570,3 +570,20 @@ test("a fresh annotation batch starts at one after chat clears or sends the prio
assert.equal(f.store.getSnapshot().draft?.text, "Keep this draft");
f.store.dispose();
});
+
+test("standard hosts receive complete annotation messages and cleared context after sending", async () => {
+ const messages: Parameters[0][] = [];
+ const updates: Parameters[0][] = [];
+ const context = new PanelContext({
+ getHostCapabilities: () => ({ updateModelContext: { text: {}, structuredContent: {} }, message: { text: {} } }),
+ async updateModelContext(params) { updates.push(params); return {}; },
+ async sendMessage(params) { messages.push(params); return {}; },
+ } as App, {} as OpenAIExtensions);
+ await context.attachAnnotation(annotation);
+ assert.equal(context.screenAnnotations.length, 1);
+ await context.sendAnnotationsToChat(simulator.udid);
+ assert.match(JSON.stringify(messages[0].content), /Make this button larger/);
+ assert.equal(messages[0]._meta, undefined);
+ assert.equal(context.attachmentCount, 0);
+ assert.deepEqual(updates.at(-1)?.structuredContent?.annotationIds, []);
+});
diff --git a/tests/tracesift-ui.test.ts b/tests/tracesift-ui.test.ts
index b07d5790..6ca4b25b 100644
--- a/tests/tracesift-ui.test.ts
+++ b/tests/tracesift-ui.test.ts
@@ -31,7 +31,7 @@ test("TraceSift Investigate fetches one authoritative handoff and sends it to th
assert.equal(calls.length, 1); assert.equal(messages.length, 1);
assert.deepEqual(calls[0], { name: "mobile_get_tracesift_handoff", arguments: { analysisId: "private-analysis", cardId: "react-commit-1-2" } });
assert.equal((messages[0] as { content: Array<{ text: string }> }).content[0].text, "PRIVATE_HANDOFF");
- assert.deepEqual((messages[0] as { _meta: unknown })._meta, { "openai/message": { target: "active", send: true } });
+ assert.equal((messages[0] as { _meta: unknown })._meta, undefined);
});
test("TraceSift disables investigation when host capabilities are absent", async () => {
@@ -544,7 +544,7 @@ test("TraceSift sends the selected frames' measured evidence to the active chat"
assert.match(text, /60 ms including everything it called/);
assert.match(text, /Search_Search rendered the list/);
assert.match(text, /← selection #1/);
- assert.deepEqual((messages[0] as { _meta: unknown })._meta, { "openai/message": { target: "active", send: true } });
+ assert.equal((messages[0] as { _meta: unknown })._meta, undefined);
// The picks never leave the frame: no handoff round trip, so an older
// installed TraceSift answers this question just as well.
assert.equal(controller.getSnapshot().error, "");
@@ -690,7 +690,7 @@ test("TraceSift sends the selected components' measured evidence to the active c
assert.match(text, /Location: `Screen` › `Chart`\./);
assert.match(text, /Treat each selection and render reason as a lead, not proof/);
assert.doesNotMatch(text, /Heavy component|Where those components sit/);
- assert.deepEqual((messages[0] as { _meta: unknown })._meta, { "openai/message": { target: "active", send: true } });
+ assert.equal((messages[0] as { _meta: unknown })._meta, undefined);
assert.equal(controller.getSnapshot().error, "");
controller.dispose();
});
diff --git a/tests/workspace-opening.test.ts b/tests/workspace-opening.test.ts
index 359e77bd..1b41a558 100644
--- a/tests/workspace-opening.test.ts
+++ b/tests/workspace-opening.test.ts
@@ -11,10 +11,11 @@ const built = await build({
define: { "process.env.NODE_ENV": '"production"' },
});
-for (const notification of ["input", "result"] as const) test(`workspace applies Android intent from host ${notification} before connecting`, async t => {
+for (const host of ["codex", "cursor", "vscode"] as const)
+for (const notification of ["input", "result"] as const) test(`${host} workspace applies Android intent from host ${notification} before connecting`, async t => {
const identity = '';
const telemetryMeta = notification === "input" ? identity : '';
- const dom = new JSDOM(`${telemetryMeta}`, {
+ const dom = new JSDOM(`${telemetryMeta}`, {
pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test/",
});
const window = dom.window;
@@ -33,6 +34,7 @@ for (const notification of ["input", "result"] as const) test(`workspace applies
Object.defineProperty(window.HTMLElement.prototype, "clientWidth", { get: () => 400 });
Object.defineProperty(window.HTMLElement.prototype, "clientHeight", { get: () => 800 });
const calls: string[] = [];
+ const sizes: number[] = [];
let initialized = false;
let tornDown = false;
function send(message: object) {
@@ -45,6 +47,8 @@ for (const notification of ["input", "result"] as const) test(`workspace applies
result(message.id, { protocolVersion: "2026-01-26", hostInfo: { name: "test", version: "1" }, hostCapabilities: { serverTools: {}, serverResources: {} }, hostContext: {} });
} else if (message.method === "ui/notifications/initialized") {
initialized = true;
+ } else if (message.method === "ui/notifications/size-changed") {
+ sizes.push(message.params.height);
} else if (message.method === "tools/call") {
const name = message.params.name;
calls.push(name);
@@ -86,6 +90,11 @@ for (const notification of ["input", "result"] as const) test(`workspace applies
window.eval(built.outputFiles[0].text);
await until(() => initialized);
await setTimeout(10);
+ if (host !== "codex") {
+ assert.deepEqual(sizes, [600]);
+ assert.equal(window.document.documentElement.style.getPropertyValue("--workspace-height"), "600px");
+ assert.ok(!calls.includes("mobile_check_plugin_update"));
+ }
const earlyDiscovery = calls.filter(name => name.startsWith("mobile_list_"));
assert.deepEqual(earlyDiscovery, []);
const target = { platform: "android", kind: "emulator", deviceId: "avd:Pixel" };
From 2ba197f8cbaf849e2d2dfca4f7ca4f48b6ce801d Mon Sep 17 00:00:00 2001
From: Mike Grabowski
Date: Fri, 23 Oct 2026 15:28:54 +0400
Subject: [PATCH 2/2] feat(vscode): add inline mcp app preview
---
docs/telemetry.md | 8 ++
docs/vscode.md | 76 +++++++++++++++++++
integrations/vscode/INSTALL.md | 5 ++
package.json | 3 +
scripts/package-editor.mjs | 2 +-
scripts/package-vscode.mjs | 2 +
scripts/smoke-editor-package.mjs | 4 +-
scripts/vscode-config.mjs | 9 +++
src/server/plugin.ts | 4 +-
src/ui/chat-message.ts | 18 ++++-
src/ui/comparison-controller.ts | 7 +-
src/ui/components/chat-preparation-notice.tsx | 5 ++
src/ui/components/comparison-card.tsx | 5 +-
src/ui/components/logs-view.tsx | 5 +-
src/ui/components/recording-card.tsx | 5 +-
src/ui/components/screen-annotations.tsx | 3 +-
src/ui/components/tracesift-cards.tsx | 6 +-
src/ui/components/tracesift-explorer.tsx | 7 +-
src/ui/components/workspace.tsx | 2 +
src/ui/model-context.ts | 10 ++-
src/ui/recording-controller.ts | 7 +-
src/ui/screen-annotations.ts | 3 +-
src/ui/tracesift-controller.ts | 6 +-
src/ui/workspace-app.tsx | 5 +-
tests/host-profile.test.ts | 2 +-
tests/vscode-handoff.test.ts | 57 ++++++++++++++
26 files changed, 233 insertions(+), 33 deletions(-)
create mode 100644 docs/vscode.md
create mode 100644 integrations/vscode/INSTALL.md
create mode 100644 scripts/package-vscode.mjs
create mode 100644 scripts/vscode-config.mjs
create mode 100644 src/ui/components/chat-preparation-notice.tsx
create mode 100644 tests/vscode-handoff.test.ts
diff --git a/docs/telemetry.md b/docs/telemetry.md
index 56265596..08a9da30 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -12,6 +12,14 @@ display-mode changes use `ui.workspace.display_mode_changed`; resize failures us
on the active rendering and streaming paths. The Codex updater and its polling
are disabled outside Codex. No native telemetry changes are required.
+VS Code prepares messages in the composer without submitting a turn. Its success
+counters are `ui.annotations.prepare_success`, `ui.logs.prepare`,
+`ui.recording.ask_prepared`, `ui.recording.open_prepared`,
+`ui.comparison.ask_prepared`, `ui.tracesift.investigate_prepared` and
+`ui.tracesift.pick_investigate_prepared`. Existing send/action counters retain
+their behavior on other hosts. Message-acknowledgement timings still measure the
+host round trip; they do not measure model response time or prove submission.
+
## Volume policy
Since 0.1.164, application metrics in the UI, Node servers and native helpers use
diff --git a/docs/vscode.md b/docs/vscode.md
new file mode 100644
index 00000000..33273b64
--- /dev/null
+++ b/docs/vscode.md
@@ -0,0 +1,76 @@
+# VS Code / GitHub Copilot 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.
+Use a current VS Code with GitHub Copilot and MCP Apps enabled
+(`chat.mcp.apps.enabled`). This preview has automated protocol/package coverage;
+actual VS Code rendering, device streaming and input still need validation.
+
+## Run from source
+
+Complete repository setup, then:
+
+```sh
+npm run build
+npm run vscode:config
+```
+
+Use **MCP: Open User Configuration** and merge the `servers` entry from
+`.local-dev/vscode-mcp.json`. Preserve existing servers. Alternatively use your
+app project's `.vscode/mcp.json`. For SSH/dev-container work, keep this stdio
+server in the **local** user profile so it can reach the Mac's Xcode/ADB/devices.
+The generated Node and server paths are absolute. Restart the MCP server after
+rebuilding; regenerate configuration after moving the checkout or Node.
+
+Ask: **Open Mobile Dev and list available devices without booting anything.**
+Choose ambiguous targets in chat or in the device picker.
+
+## Relocatable bundle
+
+```sh
+npm run package:vscode
+npm run test:vscode
+```
+
+Unzip `release/mobile-dev--vscode-darwin-arm64.zip` into a persistent
+directory, run `node configure.mjs` there, then merge the printed configuration
+into VS Code. It uses the Node running that command and requires no Codex
+installation. Replace the bundle and restart the server to update. Codex update
+checks and installation are disabled. This is an MCP bundle, not a VSIX.
+
+Local builds retain development telemetry. Public packaging requires
+`npm run build:release` and `npm run package:vscode -- --release`, plus the normal
+source-map and native-symbol upload workflow.
+
+## VS Code behavior
+
+- The workspace runs inline at a reported bounded height, with responsive
+ device/tools layout. It declares only inline mode. A docked editor panel or
+ fullscreen workspace would require separate VS Code extension work.
+- **Prepare question** / **Prepare investigation** fills the chat input. Review
+ the text and press **Send** to start. Logs, screenshot context and annotations
+ remain selected until explicitly cleared; a prepared question is not a sent
+ turn. Questions include their selected log/recording details.
+- The explicit VS Code profile accommodates versions that handle `ui/message`
+ but omit `message.text`, and context updates without a `text` subcapability.
+ Other hosts retain capability checks. Rejections/timeouts are shown without
+ automatically retrying an uncertain handoff. Existing copy actions remain.
+
+## Validation before declaring support
+
+- Open an existing iOS simulator, stream for five minutes, tap and drag.
+- Confirm the inline height, keyboard/menu behavior and device/tool selectors.
+- Prepare a log/annotation/recording question, verify it does not submit, then
+ submit manually and check the agent receives the correct device and reference.
+- Check image attachments and explicit clearing, including a manually removed
+ composer attachment. Standard context provides no composer readback; local
+ selections remain app-owned and can be published again on a later update.
+- Hide/reopen cards, switch chats and verify collectors and observers clean up.
+- Validate Android H.264 and physical-iOS HEVC independently of simulator JPEG.
+
+The smoke test relocates the archive into a path with spaces and verifies MCP
+startup, UI resources, telemetry opt-out and update isolation without booting
+devices. Existing stream/input telemetry remains; prepared-message outcomes use
+separate counters described in `docs/telemetry.md`.
+
+Source: [VS Code MCP developer guide](https://code.visualstudio.com/api/extension-guides/ai/mcp).
diff --git a/integrations/vscode/INSTALL.md b/integrations/vscode/INSTALL.md
new file mode 100644
index 00000000..7d917834
--- /dev/null
+++ b/integrations/vscode/INSTALL.md
@@ -0,0 +1,5 @@
+Run `node configure.mjs` from this extracted bundle. Merge the printed `servers`
+entry into the local user-profile MCP configuration in VS Code. The generated
+configuration uses absolute paths and does not modify any editor settings.
+
+See README.md for prerequisites, update instructions and preview limitations.
diff --git a/package.json b/package.json
index 221c458f..de40ff87 100644
--- a/package.json
+++ b/package.json
@@ -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",
+ "vscode:config": "node scripts/vscode-config.mjs",
+ "package:vscode": "node scripts/package-vscode.mjs",
+ "test:vscode": "node scripts/smoke-editor-package.mjs vscode",
"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/package-editor.mjs b/scripts/package-editor.mjs
index 73fa8fc4..a4d1c2d5 100644
--- a/scripts/package-editor.mjs
+++ b/scripts/package-editor.mjs
@@ -30,5 +30,5 @@ export async function packageEditor(host) {
await rm(archive, { force: true });
execFileSync("zip", ["-qr", archive, "."], { cwd: directory });
execFileSync("unzip", ["-tq", archive], { stdio: "inherit" });
- console.log(`${host} development bundle: ${archive}`);
+ console.log(`${host} bundle: ${archive}`);
}
diff --git a/scripts/package-vscode.mjs b/scripts/package-vscode.mjs
new file mode 100644
index 00000000..28d23444
--- /dev/null
+++ b/scripts/package-vscode.mjs
@@ -0,0 +1,2 @@
+import { packageEditor } from "./package-editor.mjs";
+await packageEditor("vscode");
diff --git a/scripts/smoke-editor-package.mjs b/scripts/smoke-editor-package.mjs
index d1e50dc4..22f49228 100644
--- a/scripts/smoke-editor-package.mjs
+++ b/scripts/smoke-editor-package.mjs
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
-import { access, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
+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";
@@ -10,7 +10,7 @@ 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 mkdtemp(join(tmpdir(), `mobile-dev-${host}-`));
+const temporary = await realpath(await mkdtemp(join(tmpdir(), `mobile-dev-${host}-`)));
try {
const directory = join(temporary, "bundle with spaces");
await mkdir(directory);
diff --git a/scripts/vscode-config.mjs b/scripts/vscode-config.mjs
new file mode 100644
index 00000000..0ff98abb
--- /dev/null
+++ b/scripts/vscode-config.mjs
@@ -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("vscode");
+await mkdir(".local-dev", { recursive: true });
+const path = resolve(".local-dev/vscode-mcp.json");
+await writeFile(path, JSON.stringify(config, null, 2) + "\n");
+console.log(`Merge the mobile-dev entry from ${path} into VS Code's local user-profile mcp.json or your project's .vscode/mcp.json, preserving existing servers.`);
diff --git a/src/server/plugin.ts b/src/server/plugin.ts
index 7bb89881..d1a36244 100644
--- a/src/server/plugin.ts
+++ b/src/server/plugin.ts
@@ -185,7 +185,7 @@ export async function createPlugin(html: string | (() => Promise), b
? content.replace('data-view="panel"', 'data-view="workspace"').replace('data-layout="stacked"', 'data-layout="split"')
: content,
_meta: {
- ui: { csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } },
+ ui: { ...(standardHost ? { permissions: { clipboardWrite: {} } } : {}), csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } },
"openai/ui": { preferredDisplayMode: "fullscreen", availableDisplayModes: ["inline", "fullscreen"] },
},
}],
@@ -214,7 +214,7 @@ export async function createPlugin(html: string | (() => Promise), b
const configured = configureUI(resource.html);
const content = configured.replace('data-view="panel"', `data-view="${view}"`);
return { contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: content, _meta: {
- ui: { prefersBorder: true, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } },
+ ui: { prefersBorder: true, ...(standardHost ? { permissions: { clipboardWrite: {} } } : {}), csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } },
"openai/ui": { preferredDisplayMode: "inline", availableDisplayModes: ["inline", "fullscreen"] },
} }] };
};
diff --git a/src/ui/chat-message.ts b/src/ui/chat-message.ts
index e2c575ad..70cacb48 100644
--- a/src/ui/chat-message.ts
+++ b/src/ui/chat-message.ts
@@ -1,9 +1,25 @@
import type { App } from "@modelcontextprotocol/ext-apps";
+export function preparesChatMessages() {
+ return typeof document !== "undefined" && document.documentElement?.dataset.host === "vscode";
+}
+
+export function canMessage(app: Pick) {
+ const capabilities = app.getHostCapabilities();
+ // VS Code implements ui/message but some versions omit message.text in the
+ // handshake. Limit this adapter to the explicitly configured distribution.
+ return !!capabilities?.message?.text || !!(preparesChatMessages() && capabilities?.serverTools && capabilities?.serverResources);
+}
+
+export function canUpdateTextContext(app: Pick) {
+ const context = app.getHostCapabilities()?.updateModelContext;
+ return !!context?.text || !!(preparesChatMessages() && context);
+}
+
// Standard hosts receive a self-contained user message. Codex can additionally
// route it to the active composer when it advertises that extension.
export function chatMessage(app: Pick, text: string): Parameters[0] {
- const routed = app.getHostCapabilities()?.experimental?.["openai/message"] != null;
+ const routed = !preparesChatMessages() && app.getHostCapabilities()?.experimental?.["openai/message"] != null;
return {
role: "user", content: [{ type: "text", text }],
...(routed ? { _meta: { "openai/message": { target: "active", send: true } } } : {}),
diff --git a/src/ui/comparison-controller.ts b/src/ui/comparison-controller.ts
index 971c08c1..377242b3 100644
--- a/src/ui/comparison-controller.ts
+++ b/src/ui/comparison-controller.ts
@@ -1,4 +1,4 @@
-import { chatMessage } from "./chat-message.ts";
+import { chatMessage, canMessage, preparesChatMessages } from "./chat-message.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { comparisonDuration, comparisonSchema } from "../shared/performance-comparison.ts";
@@ -36,8 +36,7 @@ export class ComparisonController {
}
hostChanged() {
const capabilities = this.app.getHostCapabilities();
- const canMessage = Boolean(capabilities?.message?.text);
- this.update({ canMessage });
+ this.update({ canMessage: canMessage(this.app) });
}
accept(result: CallToolResult) {
if (this.disposed) return;
@@ -105,7 +104,7 @@ export class ComparisonController {
try {
const result = await this.app.sendMessage(message);
if (result.isError) throw new Error("The host could not send this comparison to chat.");
- countUiEvent("ui.comparison.ask");
+ countUiEvent(preparesChatMessages() ? "ui.comparison.ask_prepared" : "ui.comparison.ask");
const elapsed = performance.now() - startedAt;
recordUiTiming("ui.comparison.message_ack", elapsed);
} catch (error) {
diff --git a/src/ui/components/chat-preparation-notice.tsx b/src/ui/components/chat-preparation-notice.tsx
new file mode 100644
index 00000000..e5d55309
--- /dev/null
+++ b/src/ui/components/chat-preparation-notice.tsx
@@ -0,0 +1,5 @@
+import { preparesChatMessages } from "../chat-message.ts";
+
+export function ChatPreparationNotice() {
+ return preparesChatMessages() ? Questions are prepared in chat. Review the text and press Send to start.
: null;
+}
diff --git a/src/ui/components/comparison-card.tsx b/src/ui/components/comparison-card.tsx
index bb01aacc..2cd08e37 100644
--- a/src/ui/components/comparison-card.tsx
+++ b/src/ui/components/comparison-card.tsx
@@ -1,3 +1,5 @@
+import { preparesChatMessages } from "../chat-message.ts";
+import { ChatPreparationNotice } from "./chat-preparation-notice.tsx";
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { PointerEvent } from "react";
import { CartesianGrid, Line, LineChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
@@ -127,6 +129,7 @@ export function ComparisonCard({ controller }: { controller: ComparisonControlle
const hasDisplayData = derived.total.some(run => run.summary !== null && (run.summary.averageFps !== null || run.summary.frameStats !== null));
const differentDurations = comparison.recordings.some(recording => recording.durationSeconds !== derived.duration);
return
+
{comparison.title}
{comparison.recordings.length} runs · Aligned at recording start · {derived.duration}s timeline
@@ -164,7 +167,7 @@ export function ComparisonCard({ controller }: { controller: ComparisonControlle
{mixedMemory && RSS and physical footprint measure different memory definitions and use separate tracks.
}
{hasDisplayData && Display FPS and frame statistics measure the whole device. Jank rate uses classified presented frames; — means unavailable.
}
{comparison.recordings.map(recording => recording.error && {recording.title}: {recording.error}
)}
-
+
{state.canMessage === false && This host does not support sending chart selections to chat.
}
{state.error && {state.error}
}
diff --git a/src/ui/components/logs-view.tsx b/src/ui/components/logs-view.tsx
index 73678c64..5732ec9b 100644
--- a/src/ui/components/logs-view.tsx
+++ b/src/ui/components/logs-view.tsx
@@ -1,3 +1,4 @@
+import { preparesChatMessages } from "../chat-message.ts";
import { CopyIcon, MessageCircleIcon, ArrowDownToLineIcon, CircleAlertIcon, InfoIcon, TriangleAlertIcon, CheckIcon, LayersIcon, ListXIcon, PauseIcon, PlayIcon, RefreshCwIcon, SearchIcon, SlidersHorizontalIcon, TerminalIcon, UnlinkIcon, XIcon, CircleHelpIcon } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { LegendList, type LegendListRef, type LegendListRenderItemProps } from "@legendapp/list/react";
@@ -77,7 +78,7 @@ export const LogsView = memo(function LogsView({ panel }: { panel: LogsPanel })
{ setCopyStatus(""); void copyLog(item); }}>Copy
void panel.list.sendToChat(item)}>
- {item.level === "error" || item.level === "warn" ? "Fix in chat" : "Ask in chat"}
+ {preparesChatMessages() ? "Prepare question" : item.level === "error" || item.level === "warn" ? "Fix in chat" : "Ask in chat"}
;
@@ -215,7 +216,7 @@ export const LogsView = memo(function LogsView({ panel }: { panel: LogsPanel })
{logs.selected && <> panel.list.select()}>
-
+
>}
diff --git a/src/ui/components/recording-card.tsx b/src/ui/components/recording-card.tsx
index b1a3df66..aa47f8f6 100644
--- a/src/ui/components/recording-card.tsx
+++ b/src/ui/components/recording-card.tsx
@@ -1,3 +1,5 @@
+import { preparesChatMessages } from "../chat-message.ts";
+import { ChatPreparationNotice } from "./chat-preparation-notice.tsx";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { PointerEvent, ReactNode } from "react";
import { Area, AreaChart, CartesianGrid, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
@@ -187,6 +189,7 @@ export function RecordingCard({ controller, detailed = false }: { controller: Re
const hasFrames = frameStats !== null && frameStats.frameCount > 0;
const hasReadings = hasCpu || hasMemory || hasFps || hasFrames;
return
+ {!detailed && }
{recording.title}
{recording.deviceName} · {recording.durationSeconds}s · {savedAt}
{status}
@@ -219,7 +222,7 @@ export function RecordingCard({ controller, detailed = false }: { controller: Re
}
}
-
+
{detailed === false &&
}
{state.canMessage === false &&
This host does not support sending chart selections to chat.
}
diff --git a/src/ui/components/screen-annotations.tsx b/src/ui/components/screen-annotations.tsx
index 0fcd9009..c104e94f 100644
--- a/src/ui/components/screen-annotations.tsx
+++ b/src/ui/components/screen-annotations.tsx
@@ -1,3 +1,4 @@
+import { preparesChatMessages } from "../chat-message.ts";
import { useState, useSyncExternalStore } from "react";
import { ArrowUpIcon, CircleAlertIcon, MessageCircleIcon, MousePointer2Icon, SendIcon, Trash2Icon } from "lucide-react";
import type { ScreenAnnotationsStore } from "../screen-annotations";
@@ -60,7 +61,7 @@ export function ScreenAnnotationOverlay({ store }: { store: ScreenAnnotationsSto
{!!state.annotations.length && !draft &&
{state.annotations.map(annotation => )}
{state.sendError &&
}
- {state.canSend &&
}
+ {state.canSend &&
}
}
;
}
diff --git a/src/ui/components/tracesift-cards.tsx b/src/ui/components/tracesift-cards.tsx
index 7ddabe02..34a35535 100644
--- a/src/ui/components/tracesift-cards.tsx
+++ b/src/ui/components/tracesift-cards.tsx
@@ -1,3 +1,5 @@
+import { preparesChatMessages } from "../chat-message.ts";
+import { ChatPreparationNotice } from "./chat-preparation-notice.tsx";
/**
* TraceSift's findings inline in the chat: the same card the TraceSift tab draws.
* Only the actions differ — Explore opens here, Investigate asks the model.
@@ -66,7 +68,7 @@ function CardFooter({ cardId, controller, canExplore }: { cardId: string; contro
title={state.canInvestigate ? "Hand this card to the chat and have it read the task." : undefined}
onClick={() => void controller.investigate(cardId)}
>
- {state.busyCard === cardId ? "Starting…" : "Investigate"}
+ {state.busyCard === cardId ? (preparesChatMessages() ? "Preparing…" : "Starting…") : preparesChatMessages() ? "Prepare investigation" : "Investigate"}
@@ -143,12 +145,14 @@ export function TraceSiftCards({ controller }: { controller: TraceSiftController
if (state.explorer) return
;
if (!analysis) return
+
Analyzing profile with TraceSift…
{state.error && {state.error}
}
;
const cpu = analysis.profileType === "cpu";
return
+
TraceSift · {cpu ? "CPU profile" : "React profile"}
diff --git a/src/ui/components/tracesift-explorer.tsx b/src/ui/components/tracesift-explorer.tsx
index 2090b120..1954ead7 100644
--- a/src/ui/components/tracesift-explorer.tsx
+++ b/src/ui/components/tracesift-explorer.tsx
@@ -1,3 +1,5 @@
+import { ChatPreparationNotice } from "./chat-preparation-notice.tsx";
+import { preparesChatMessages } from "../chat-message.ts";
/**
* The full Explore view inside the card's frame, drawn from the same package the
* web app uses. Its data arrives over MCP: this frame cannot reach the service.
@@ -27,6 +29,7 @@ export function TraceSiftExplorer({ controller }: { controller: TraceSiftControl
const count = explorer.selected?.length ?? 0;
const noun = count === 1 ? picks.one : picks.many;
return
+