From 0f981ff2afadfb2c19f76a63fdd617ea3092c5fb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 21 Aug 2026 08:14:04 +0300 Subject: [PATCH] fix(mcp): isolate Portless development routes - Register the Vite-owned MCP child as a worktree-prefixed Portless sibling service\n- Inject HTTPS and WebSocket automation URLs into the browser instead of assuming port 7600\n- Isolate development socket and discovery files while preserving fixed-port non-Portless flows --- AGENTS.md | 2 +- CHANGELOG.md | 2 + README.md | 2 +- src/app/automation/bridge/portless-route.ts | 37 ++++++++++++++ src/app/automation/bridge/server.ts | 9 ++-- src/app/automation/bridge/vite-plugin.ts | 50 +++++++++++++++---- src/app/automation/mcp/spawn.ts | 5 +- src/env.d.ts | 2 + .../app/automation/mcp-vite-plugin.test.ts | 3 ++ .../app/automation/portless-route.test.ts | 38 ++++++++++++++ vite.config.ts | 9 +++- vite/automation.ts | 10 +++- 12 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 src/app/automation/bridge/portless-route.ts create mode 100644 tests/engine/app/automation/portless-route.test.ts diff --git a/AGENTS.md b/AGENTS.md index eb3809ac4..386b959a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ App dialogs compose the Reka-backed components under `src/components/ui/dialog/` ## Git worktrees and development servers -Use `bun run dev:portless` for browser development, especially in worktrees; Portless assigns the main checkout `https://open-pencil.localhost` and each worktree a branch-prefixed URL. Keep `bun run dev` for Playwright, Tauri, and Dev Container flows that require `http://localhost:1420`. +Use `bun run dev:portless` for browser development, especially in worktrees; Portless assigns the main checkout `https://open-pencil.localhost` and each worktree a branch-prefixed URL. The Vite-owned development MCP server is registered as the matching `mcp.open-pencil` sibling service (for example, `https://fix-ui.mcp.open-pencil.localhost`) and uses isolated runtime socket/discovery paths. Keep `bun run dev` for Playwright, Tauri, and Dev Container flows that require `http://localhost:1420` and the fixed local automation port. ## Releases & CI diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5bf514a..f4ee9dc31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ ### Fixed +- Isolate browser-development MCP servers behind worktree-aware Portless WebSocket routes and per-runtime socket/discovery paths, preventing concurrent worktrees from competing for port 7600 or the global MCP socket. + - Generate and cache recent-file previews from the conventional `Cover` page after opening a `.fig`, without modifying the source file. - Preserve app-created component properties and instance-swap targets across `.fig` save and reload cycles. (#548) - Reconnect desktop automation to an already-running MCP server by allowing access to its discovery file. (#546) diff --git a/README.md b/README.md index 3989987fc..b54f606d0 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ bun run dev # Direct Vite server at http://localhost:1420 bun run tauri dev # Desktop app (requires Rust) ``` -The first Portless run creates and trusts a local HTTPS certificate. Linked Git worktrees automatically receive branch-prefixed URLs such as `https://fix-ui.open-pencil.localhost`, so concurrent development servers do not compete for port 1420. Run `bunx portless doctor` if local routing or certificate trust fails. +The first Portless run creates and trusts a local HTTPS certificate. Linked Git worktrees automatically receive branch-prefixed URLs such as `https://fix-ui.open-pencil.localhost`, so concurrent development servers do not compete for port 1420. Their development MCP bridges are exposed through matching sibling URLs such as `https://fix-ui.mcp.open-pencil.localhost`, with isolated TCP ports and runtime socket files. Run `bunx portless doctor` if local routing or certificate trust fails. Alternatively, open the repository in any [Dev Container](https://containers.dev/)-compatible tool. The container pins Bun, installs the workspace dependencies, and forwards the direct web editor on port 1420. Start it with `bun run dev` after the container is ready. diff --git a/src/app/automation/bridge/portless-route.ts b/src/app/automation/bridge/portless-route.ts new file mode 100644 index 000000000..4d952a873 --- /dev/null +++ b/src/app/automation/bridge/portless-route.ts @@ -0,0 +1,37 @@ +export interface DevAutomationRoute { + browserURL: string + corsOrigin: string + portlessServiceName: string | null + runtimeId: string +} + +const APP_NAME = 'open-pencil' +const MCP_SERVICE_NAME = `mcp.${APP_NAME}` + +export function devAutomationRoute( + portlessURL: string | undefined, + fallbackPort: number +): DevAutomationRoute { + if (!portlessURL) { + return { + browserURL: `ws://127.0.0.1:${fallbackPort}`, + corsOrigin: 'http://localhost:1420', + portlessServiceName: null, + runtimeId: `localhost-${fallbackPort}` + } + } + + const appURL = new URL(portlessURL) + const marker = `${APP_NAME}.` + const markerIndex = appURL.hostname.lastIndexOf(marker) + if (markerIndex === -1) throw new Error(`Unexpected OpenPencil Portless URL: ${portlessURL}`) + const prefix = appURL.hostname.slice(0, markerIndex) + const suffix = appURL.hostname.slice(markerIndex + APP_NAME.length) + const mcpHostname = `${prefix}${MCP_SERVICE_NAME}${suffix}` + return { + browserURL: `wss://${mcpHostname}`, + corsOrigin: appURL.origin, + portlessServiceName: MCP_SERVICE_NAME, + runtimeId: mcpHostname + } +} diff --git a/src/app/automation/bridge/server.ts b/src/app/automation/bridge/server.ts index 37def406d..789b83f82 100644 --- a/src/app/automation/bridge/server.ts +++ b/src/app/automation/bridge/server.ts @@ -4,14 +4,17 @@ * Connects to the bridge via WebSocket, receives RPC requests, * executes them against the live EditorStore, and sends results back. */ -import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants' import { randomHex } from '@open-pencil/core/random' import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory' import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers' import type { EditorStore } from '@/app/editor/active-store' -export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) { +export function connectAutomation( + getStore: () => EditorStore, + authToken: string | null = null, + automationURL = __OPENPENCIL_LOCAL_AUTOMATION_URL__ +) { const token = authToken ?? randomHex(32) let ws: WebSocket | null = null let reconnectTimer: ReturnType | undefined @@ -27,7 +30,7 @@ export function connectAutomation(getStore: () => EditorStore, authToken: string function connect() { let socket: WebSocket try { - socket = new WebSocket(`ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`) + socket = new WebSocket(automationURL) ws = socket } catch (e) { console.error( diff --git a/src/app/automation/bridge/vite-plugin.ts b/src/app/automation/bridge/vite-plugin.ts index 5a00bb7a4..5af41db5c 100644 --- a/src/app/automation/bridge/vite-plugin.ts +++ b/src/app/automation/bridge/vite-plugin.ts @@ -1,11 +1,14 @@ import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir } from 'node:fs/promises' import type { IncomingMessage } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { Plugin } from 'vite' -import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants' import { serializeDisabledTools } from '@open-pencil/mcp/tools' -import { getSocketPath, platformHasUnixSockets } from '@open-pencil/mcp/transport' +import { platformHasUnixSockets } from '@open-pencil/mcp/transport' import { DEV_MCP_RESTART_PATH, @@ -18,21 +21,25 @@ interface AutomationEnvironmentOptions { baseEnv: NodeJS.ProcessEnv configuration: DevMCPConfiguration corsOrigin: string + discoveryPath: string | null + httpPort: number socketPath: string | null } export function createAutomationEnvironment( options: AutomationEnvironmentOptions ): NodeJS.ProcessEnv { - const { authToken, baseEnv, configuration, corsOrigin, socketPath } = options + const { authToken, baseEnv, configuration, corsOrigin, discoveryPath, httpPort, socketPath } = + options const childEnv = { ...baseEnv } delete childEnv.OPENPENCIL_MCP_SOCKET delete childEnv.OPENPENCIL_MCP_AUTH_TOKEN return { ...childEnv, - PORT: String(AUTOMATION_HTTP_PORT), + PORT: String(httpPort), OPENPENCIL_MCP_TCP: '1', ...(socketPath ? { OPENPENCIL_MCP_SOCKET: socketPath } : {}), + ...(discoveryPath ? { OPENPENCIL_MCP_DISCOVERY_PATH: discoveryPath } : {}), OPENPENCIL_MCP_AUTH_TOKEN: configuration.authenticationEnabled ? (authToken ?? '') : '', OPENPENCIL_MCP_CORS_ORIGIN: corsOrigin, OPENPENCIL_MCP_ROOT: configuration.rootDirectory.trim() || process.cwd(), @@ -90,8 +97,23 @@ export async function readDevMCPConfiguration(request: IncomingMessage): Promise } } +interface AutomationPluginOptions { + browserURL: string + corsOrigin: string + httpPort: number + portlessServiceName: string | null + runtimeId: string +} + +function safeRuntimeId(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 16) +} + // TODO: production — bundle MCP server as Tauri sidecar or spawn via shell plugin -export function automationPlugin(authToken: string | null, corsOrigin: string): Plugin { +export function automationPlugin( + authToken: string | null, + options: AutomationPluginOptions +): Plugin { let child: ReturnType | null = null let lifecycle = Promise.resolve() let configuration: DevMCPConfiguration = { @@ -127,14 +149,24 @@ export function automationPlugin(authToken: string | null, corsOrigin: string): } async function startChild(): Promise { - const socketPath = platformHasUnixSockets() ? await getSocketPath() : null - const spawned = spawn('bun', ['run', 'packages/mcp/src/index.ts'], { + const runtimeDir = join(tmpdir(), 'open-pencil-mcp', safeRuntimeId(options.runtimeId)) + await mkdir(runtimeDir, { recursive: true, mode: 0o700 }) + const socketPath = platformHasUnixSockets() ? join(runtimeDir, 'mcp.sock') : null + const discoveryPath = join(runtimeDir, 'mcp.json') + const command = ['bun', 'run', 'packages/mcp/src/index.ts'] + const spawnCommand = options.portlessServiceName ? 'portless' : command[0] + const spawnArgs = options.portlessServiceName + ? ['run', '--name', options.portlessServiceName, ...command] + : command.slice(1) + const spawned = spawn(spawnCommand, spawnArgs, { stdio: ['ignore', 'inherit', 'pipe'], env: createAutomationEnvironment({ authToken, baseEnv: process.env, configuration, - corsOrigin, + corsOrigin: options.corsOrigin, + discoveryPath, + httpPort: options.httpPort, socketPath }) }) @@ -149,7 +181,7 @@ export function automationPlugin(authToken: string | null, corsOrigin: string): const text = data.toString() if (text.includes('EADDRINUSE')) { console.error( - `\x1b[31m[MCP] MCP bind failed (port ${AUTOMATION_HTTP_PORT}${socketPath ? ` or socket ${socketPath}` : ''}). Is another OpenPencil instance running?\x1b[0m` + `\x1b[31m[MCP] MCP bind failed (${options.browserURL}${socketPath ? ` or socket ${socketPath}` : ''}). Is another OpenPencil instance running?\x1b[0m` ) spawned.kill() if (child === spawned) child = null diff --git a/src/app/automation/mcp/spawn.ts b/src/app/automation/mcp/spawn.ts index 3123949ec..b14928c40 100644 --- a/src/app/automation/mcp/spawn.ts +++ b/src/app/automation/mcp/spawn.ts @@ -31,6 +31,9 @@ export interface AutomationServerHandle { managed: boolean } +const DEV_AUTOMATION_HTTP_URL = import.meta.env.DEV + ? __OPENPENCIL_LOCAL_AUTOMATION_HTTP_URL__ + : `http://127.0.0.1:${AUTOMATION_HTTP_PORT}` const DEV_AUTOMATION_AUTH_TOKEN = import.meta.env.DEV && typeof __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__ === 'string' ? __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__ @@ -231,7 +234,7 @@ export async function readAutomationHealth( ): Promise { try { const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined - const res = await fetch(`http://127.0.0.1:${AUTOMATION_HTTP_PORT}/health`, { + const res = await fetch(`${DEV_AUTOMATION_HTTP_URL}/health`, { headers, signal: AbortSignal.timeout(1000) }) diff --git a/src/env.d.ts b/src/env.d.ts index c3bd7fff9..08bfbd4a1 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -4,6 +4,8 @@ declare const __OPENPENCIL_APP_VERSION__: string declare const __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: string | null +declare const __OPENPENCIL_LOCAL_AUTOMATION_URL__: string +declare const __OPENPENCIL_LOCAL_AUTOMATION_HTTP_URL__: string declare module '*.vue' { import type { DefineComponent } from 'vue' diff --git a/tests/engine/app/automation/mcp-vite-plugin.test.ts b/tests/engine/app/automation/mcp-vite-plugin.test.ts index 03f2bd912..ecc8998dd 100644 --- a/tests/engine/app/automation/mcp-vite-plugin.test.ts +++ b/tests/engine/app/automation/mcp-vite-plugin.test.ts @@ -21,12 +21,15 @@ describe('MCP Vite development server', () => { disabledTools: ['eval', 'delete_node'] }, corsOrigin: 'http://localhost:1420', + discoveryPath: '/tmp/mcp.json', + httpPort: 7600, socketPath: '/tmp/open-pencil.sock' }) expect(env.OPENPENCIL_MCP_AUTH_TOKEN).toBe('') expect(env.OPENPENCIL_MCP_ROOT).toBe('/designs') expect(env.OPENPENCIL_MCP_DISABLED_TOOLS).toBe('eval,delete_node') + expect(env.OPENPENCIL_MCP_DISCOVERY_PATH).toBe('/tmp/mcp.json') }) test('normalizes and validates typed disabled tool configuration', () => { diff --git a/tests/engine/app/automation/portless-route.test.ts b/tests/engine/app/automation/portless-route.test.ts new file mode 100644 index 000000000..e2602c069 --- /dev/null +++ b/tests/engine/app/automation/portless-route.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' + +import { devAutomationRoute } from '@/app/automation/bridge/portless-route' + +describe('Portless MCP routing', () => { + test('uses the fixed localhost bridge outside Portless', () => { + expect(devAutomationRoute(undefined, 7600)).toEqual({ + browserURL: 'ws://127.0.0.1:7600', + corsOrigin: 'http://localhost:1420', + portlessServiceName: null, + runtimeId: 'localhost-7600' + }) + }) + + test('derives a sibling MCP service for the main checkout', () => { + expect(devAutomationRoute('https://open-pencil.localhost', 7600)).toEqual({ + browserURL: 'wss://mcp.open-pencil.localhost', + corsOrigin: 'https://open-pencil.localhost', + portlessServiceName: 'mcp.open-pencil', + runtimeId: 'mcp.open-pencil.localhost' + }) + }) + + test('preserves the worktree prefix for the MCP service', () => { + expect(devAutomationRoute('https://portless-mcp-routing.open-pencil.localhost', 7600)).toEqual({ + browserURL: 'wss://portless-mcp-routing.mcp.open-pencil.localhost', + corsOrigin: 'https://portless-mcp-routing.open-pencil.localhost', + portlessServiceName: 'mcp.open-pencil', + runtimeId: 'portless-mcp-routing.mcp.open-pencil.localhost' + }) + }) + + test('rejects unrelated Portless hostnames', () => { + expect(() => devAutomationRoute('https://other.localhost', 7600)).toThrow( + 'Unexpected OpenPencil Portless URL' + ) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 67f965485..2674294fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,8 @@ import Components from 'unplugin-vue-components/vite' import { defineConfig } from 'vite' import packageJson from './package.json' +import { AUTOMATION_HTTP_PORT } from './packages/core/src/constants' +import { devAutomationRoute } from './src/app/automation/bridge/portless-route' import { createOpenPencilAliases } from './vite/aliases' import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation' import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets' @@ -16,6 +18,7 @@ import { rawMarkdownPlugin } from './vite/raw-markdown' import { createDevServerOptions } from './vite/server' const host = process.env.TAURI_DEV_HOST +const automationRoute = devAutomationRoute(process.env.PORTLESS_URL, AUTOMATION_HTTP_PORT) export default defineConfig(async ({ command }) => ({ resolve: { @@ -23,7 +26,11 @@ export default defineConfig(async ({ command }) => ({ }, define: { __OPENPENCIL_APP_VERSION__: JSON.stringify(packageJson.version), - __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify(localAutomationToken(command)) + __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify(localAutomationToken(command)), + __OPENPENCIL_LOCAL_AUTOMATION_URL__: JSON.stringify(automationRoute.browserURL), + __OPENPENCIL_LOCAL_AUTOMATION_HTTP_URL__: JSON.stringify( + automationRoute.browserURL.replace(/^ws/, 'http') + ) }, plugins: [ rawMarkdownPlugin(), diff --git a/vite/automation.ts b/vite/automation.ts index 01328e81f..880a4bc4e 100644 --- a/vite/automation.ts +++ b/vite/automation.ts @@ -1,6 +1,9 @@ import { randomUUID } from 'node:crypto' import process from 'node:process' +import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants' + +import { devAutomationRoute } from '../src/app/automation/bridge/portless-route' import { automationPlugin } from '../src/app/automation/bridge/vite-plugin' const devAutomationAuthToken = process.env.OPENPENCIL_DEV_TOKEN ?? randomUUID() @@ -14,5 +17,10 @@ export function automationCORSOrigin(host: string | undefined): string { } export function openPencilAutomationPlugin(command: string, host: string | undefined) { - return automationPlugin(localAutomationToken(command), automationCORSOrigin(host)) + const route = devAutomationRoute(process.env.PORTLESS_URL, AUTOMATION_HTTP_PORT) + return automationPlugin(localAutomationToken(command), { + ...route, + corsOrigin: process.env.PORTLESS_URL ? route.corsOrigin : automationCORSOrigin(host), + httpPort: AUTOMATION_HTTP_PORT + }) }