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:
parent
5201404f56
commit
0f981ff2af
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
37
src/app/automation/bridge/portless-route.ts
Normal file
37
src/app/automation/bridge/portless-route.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
src/env.d.ts
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
38
tests/engine/app/automation/portless-route.test.ts
Normal file
38
tests/engine/app/automation/portless-route.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue