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 =