From b7fda1d4065d1dd1f85942e8706818a67f2838b6 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Fri, 18 Sep 2026 03:37:20 +0300 Subject: [PATCH] fix(automation): allow a configurable canvas endpoint and browser-only token - connectAutomation accepts an explicit ws:// URL and a register token - EditorView reads ?automationWs / ?automationToken so a served build (W4C Design Studio iframe) can reach the server MCP instead of the user's 127.0.0.1 - MCP accepts OPENPENCIL_MCP_BROWSER_TOKEN for canvas registration only, never for /mcp or /rpc, so a public canvas WebSocket does not expose the tool token --- packages/mcp/src/browser-rpc.ts | 17 +++++++++++++++-- packages/mcp/src/index.ts | 3 +++ packages/mcp/src/server.ts | 7 +++++++ src/app/automation/bridge/server.ts | 12 ++++++++++-- src/views/EditorView.vue | 19 ++++++++++++++++--- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/mcp/src/browser-rpc.ts b/packages/mcp/src/browser-rpc.ts index ed31dedde..1c66a9041 100644 --- a/packages/mcp/src/browser-rpc.ts +++ b/packages/mcp/src/browser-rpc.ts @@ -14,6 +14,13 @@ const APP_NOT_CONNECTED_MESSAGE = type BrowserRPCBridgeOptions = { authToken: string | null + /** + * Optional second token that only authorizes browser *canvas registration* + * (the `register` WebSocket message), never the HTTP `/mcp`/`/rpc` tool + * surface. W4C uses it so a served build can host a canvas over a public + * WebSocket without handing tenants the MCP tool token. + */ + browserToken?: string | null onConnectionChange: () => void } @@ -61,7 +68,7 @@ function createSettler(resolve: (value: T) => void, reject: (error: Error) => } } -export function createBrowserRPCBridge({ authToken, onConnectionChange }: BrowserRPCBridgeOptions) { +export function createBrowserRPCBridge({ authToken, browserToken, onConnectionChange }: BrowserRPCBridgeOptions) { const pending = new Map() const clients = new Set() const connectionWaiters = new Set() @@ -216,7 +223,13 @@ export function createBrowserRPCBridge({ authToken, onConnectionChange }: Browse function registerBrowser(ws: WebSocket, token: string | null) { if (bridgeClosed) return - if (!isAuthorized(token, authToken)) { + // The canvas may authenticate with the main MCP token (desktop/dev, same + // trust boundary) or with the browser-only token (served build, cannot read + // the MCP token). Only the main token also unlocks the HTTP tool surface. + const authorized = + isAuthorized(token, authToken) || + (browserToken !== null && browserToken !== undefined && isAuthorized(token, browserToken)) + if (!authorized) { ws.close() return } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8ac66e4d1..ea7128722 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -91,6 +91,9 @@ const handle = await startServer({ return trimmed })(), corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null, + // Optional browser-only token: authorizes canvas registration over the public + // WebSocket without exposing the MCP tool token to tenants. + browserToken: process.env.OPENPENCIL_MCP_BROWSER_TOKEN?.trim() || null, appAttachTimeoutMs }) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 010360b57..62e0d77ec 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -71,6 +71,12 @@ export interface ServerOptions { mcpRoot?: string | null /** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */ authToken?: string | null + /** + * Optional second token that authorizes only browser canvas registration over + * the WebSocket. Lets a served build host a canvas without exposing the MCP + * tool token. Ignored for /mcp and /rpc. + */ + browserToken?: string | null corsOrigin?: string | null /** * If set, the server starts a grace-period timer while no app is attached. @@ -305,6 +311,7 @@ function buildServerContext(options: ServerOptions) { }) const browserRPC = createBrowserRPCBridge({ authToken, + browserToken: options.browserToken ?? null, onConnectionChange: mcpSessions.notifyToolsChanged }) const sendToBrowser = browserRPC.sendRPC diff --git a/src/app/automation/bridge/server.ts b/src/app/automation/bridge/server.ts index 37def406d..d25cd3749 100644 --- a/src/app/automation/bridge/server.ts +++ b/src/app/automation/bridge/server.ts @@ -11,7 +11,11 @@ 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, + wsUrl: string | null = null +) { const token = authToken ?? randomHex(32) let ws: WebSocket | null = null let reconnectTimer: ReturnType | undefined @@ -27,7 +31,11 @@ export function connectAutomation(getStore: () => EditorStore, authToken: string function connect() { let socket: WebSocket try { - socket = new WebSocket(`ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`) + // Served builds (W4C Design Studio iframe) cannot reach the MCP on + // 127.0.0.1 — that is the *user's* machine, not the server. The embedder + // passes the real endpoint via `?automationWs=`; desktop/dev keep the + // loopback default where editor and MCP share a host. + socket = new WebSocket(wsUrl ?? `ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`) ws = socket } catch (e) { console.error( diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue index 6568e70b6..10413a4ae 100644 --- a/src/views/EditorView.vue +++ b/src/views/EditorView.vue @@ -103,11 +103,24 @@ onMounted(async () => { // reports "OpenPencil app is not connected" for every tool call. Allow an explicit opt-in so a // served build can host a canvas: build-time `VITE_OPENPENCIL_AUTOMATION=1`, or runtime // `?automation=1`. Normal web users without the opt-in are unaffected. + const params = new URLSearchParams(window.location.search) const automationOptIn = - import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || - new URLSearchParams(window.location.search).get('automation') === '1' + import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || params.get('automation') === '1' + // Served builds cannot reach the MCP on 127.0.0.1 (that is the *user's* machine, not the + // server). The embedder passes the reachable endpoint via `?automationWs=` plus a browser-only + // token via `?automationToken=` (the MCP tool token never leaves the backend). Desktop/dev keep + // the loopback default. + const automationWs = + params.get('automationWs') || + (import.meta.env.VITE_OPENPENCIL_AUTOMATION_URL as string | undefined) || + null + const automationToken = params.get('automationToken') || mcp?.authToken || null if (import.meta.env.DEV || (tauri && mcp) || automationOptIn) { - automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect + automationCleanup.value = connectAutomation( + getActiveStore, + automationToken, + automationWs + ).disconnect } try {