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
This commit is contained in:
Danila Poyarkov 2026-08-21 08:14:04 +03:00
parent 5201404f56
commit 0f981ff2af
12 changed files with 152 additions and 17 deletions

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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
}
}

View file

@ -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<typeof setTimeout> | 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(

View file

@ -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<typeof spawn> | 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<void> {
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

View file

@ -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<AutomationHealth | null> {
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)
})

2
src/env.d.ts vendored
View file

@ -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'

View file

@ -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', () => {

View file

@ -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'
)
})
})

View file

@ -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(),

View file

@ -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
})
}