openpencil/packages/mcp/src/browser-rpc.ts
Vitali sharp8n b7fda1d406 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
2026-09-18 03:37:20 +03:00

374 lines
12 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { WebSocket } from 'ws'
import { isAuthorized } from '#mcp/auth'
import type { RPCJSONObject } from '#mcp/json'
import type { PendingRequest } from '#mcp/rpc-types'
const RPC_TIMEOUT = 20_000
const APP_WAIT_TIMEOUT = 10_000
const APP_NOT_CONNECTED_MESSAGE =
'OpenPencil app is not connected. STOP and tell the user: "The OpenPencil desktop app is not running, no document is open, or the desktop app is connected to a different MCP server. Please start OpenPencil, open a document, and try again." Do NOT attempt to start the app yourself or retry automatically.'
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
}
type ConnectionListener = (connected: boolean) => void
type BrowserMessage = {
type: string
id?: string
token?: unknown
result?: unknown
error?: string
ok?: boolean
}
function stripEnvelope(msg: BrowserMessage): Record<string, unknown> {
const { type: _type, id: _id, ...body } = msg
return body
}
function responsePayload(result: unknown): RPCJSONObject {
if (result && typeof result === 'object' && !Array.isArray(result)) {
return result as RPCJSONObject
}
return { result }
}
function sendJSON(ws: WebSocket, body: Record<string, unknown>) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(body))
}
function createSettler<T>(resolve: (value: T) => void, reject: (error: Error) => void) {
let settled = false
return {
resolve: (value: T) => {
if (settled) return
settled = true
resolve(value)
},
reject: (error: Error) => {
if (settled) return
settled = true
reject(error)
},
isSettled: () => settled
}
}
export function createBrowserRPCBridge({ authToken, browserToken, onConnectionChange }: BrowserRPCBridgeOptions) {
const pending = new Map<string, PendingRequest>()
const clients = new Set<WebSocket>()
const connectionWaiters = new Set<PendingRequest>()
// Track which WebSocket clients have authenticated via a valid
// register message. Unauthenticated clients can only send register;
// all other message types (request, response) are rejected.
const authenticatedClients = new Set<WebSocket>()
const connectionListeners = new Set<ConnectionListener>()
let browserWs: WebSocket | null = null
let browserRegistered = false
let bridgeClosed = false
function isConnected(): boolean {
return Boolean(browserWs && browserRegistered)
}
function notifyConnectionChange() {
onConnectionChange()
const connected = isConnected()
for (const listener of connectionListeners) listener(connected)
}
function subscribeConnectionChange(listener: ConnectionListener): () => void {
connectionListeners.add(listener)
listener(isConnected())
return () => connectionListeners.delete(listener)
}
function notifyConnectionWaiters() {
for (const waiter of connectionWaiters) {
clearTimeout(waiter.timer)
waiter.resolve(undefined)
}
connectionWaiters.clear()
}
function rejectConnectionWaiters(reason: string) {
for (const waiter of connectionWaiters) {
clearTimeout(waiter.timer)
waiter.reject(new Error(reason))
}
connectionWaiters.clear()
}
function waitForConnection(): Promise<void> {
return new Promise<void>((resolve, reject) => {
let waiter: PendingRequest | null = null
const timer = setTimeout(() => {
if (waiter) connectionWaiters.delete(waiter)
reject(new Error(APP_NOT_CONNECTED_MESSAGE))
}, APP_WAIT_TIMEOUT)
waiter = {
resolve: () => {
clearTimeout(timer)
resolve()
},
reject: (error: Error) => {
clearTimeout(timer)
reject(error)
},
timer
}
// Add the waiter BEFORE checking browser state to avoid a lost-wakeup
// race: if the browser registers between sendRPC's initial check and
// this point, notifyConnectionWaiters() will have already fired and
// cleared the set. Without this re-check, the waiter would stall for
// APP_WAIT_TIMEOUT even though the browser is connected.
connectionWaiters.add(waiter)
if (browserWs && browserWs.readyState === browserWs.OPEN && browserRegistered) {
waiter.resolve(undefined)
connectionWaiters.delete(waiter)
}
})
}
function rejectAllPending(reason: string) {
for (const [, req] of pending) {
clearTimeout(req.timer)
req.reject(new Error(reason))
}
pending.clear()
}
function sendRegisterPrompt(ws: WebSocket) {
// Send a prompt inviting the client to register as the browser.
// IMPORTANT: Do NOT include the auth token here. On TCP, any local
// process can connect via WebSocket — sending the secret token would
// leak credentials to unauthenticated clients. The legitimate browser
// app already knows the token (from the discovery file or Vite env)
// and sends it proactively in its ws.onopen handler. The token field
// is set to null to signal that auth is required without revealing
// the secret. When auth is disabled (authToken === null), null is
// still correct — it means "no token needed."
sendJSON(ws, { type: 'register', token: null })
}
function broadcastRegisterPrompt() {
for (const client of clients) sendRegisterPrompt(client)
}
function sendRPC(body: Record<string, unknown>): Promise<unknown> {
if (bridgeClosed) return Promise.reject(new Error('Server shutting down'))
return new Promise((resolve, reject) => {
const doSend = () => {
const ws = browserWs
if (!ws || ws.readyState !== ws.OPEN || !browserRegistered) {
reject(new Error(APP_NOT_CONNECTED_MESSAGE))
return
}
const id = randomUUID()
const settle = createSettler(resolve, reject)
const timer = setTimeout(() => {
pending.delete(id)
settle.reject(new Error(`RPC timeout (${Math.round(RPC_TIMEOUT / 1000)}s)`))
}, RPC_TIMEOUT)
pending.set(id, { resolve: settle.resolve, reject: settle.reject, timer })
try {
ws.send(JSON.stringify({ ...body, type: 'request', id }))
} catch (e) {
clearTimeout(timer)
pending.delete(id)
if (!settle.isSettled()) {
settle.reject(e instanceof Error ? e : new Error(String(e)))
}
}
}
if (browserWs && browserWs.readyState === browserWs.OPEN && browserRegistered) {
doSend()
} else {
void waitForConnection().then(doSend).catch(reject)
}
})
}
async function handleClientRequest(ws: WebSocket, msg: BrowserMessage) {
if (!msg.id) return
try {
const result = await sendRPC(stripEnvelope(msg))
sendJSON(ws, { ...responsePayload(result), type: 'response', id: msg.id, ok: true })
} catch (e) {
sendJSON(ws, {
type: 'response',
id: msg.id,
ok: false,
error: e instanceof Error ? e.message : String(e)
})
}
}
function registerBrowser(ws: WebSocket, token: string | null) {
if (bridgeClosed) return
// 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
}
// Mark this client as authenticated — it can now send requests.
authenticatedClients.add(ws)
const previousBrowserWs = browserWs
browserWs = ws
browserRegistered = true
if (previousBrowserWs && previousBrowserWs !== ws) {
// Reject in-flight requests to the old browser. Without this, pending
// requests sit in the pending map until RPC_TIMEOUT (20s), because
// handleClose for the old socket returns early (browserWs is already
// set to the new socket, so browserWs !== previousBrowserWs).
rejectAllPending('Browser reconnected')
if (previousBrowserWs.readyState === ws.OPEN) {
previousBrowserWs.close()
}
}
notifyConnectionWaiters()
notifyConnectionChange()
broadcastRegisterPrompt()
}
function handleBrowserResponse(msg: BrowserMessage, ws: WebSocket) {
if (!browserRegistered || browserWs !== ws || !msg.id) return
const req = pending.get(msg.id)
if (!req) return
pending.delete(msg.id)
clearTimeout(req.timer)
if (msg.ok === false) {
req.reject(new Error(msg.error ?? 'RPC failed'))
} else {
req.resolve(stripEnvelope(msg))
}
}
function handleMessage(data: string, ws: WebSocket) {
if (bridgeClosed) return
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch (e) {
console.warn('Malformed automation message:', e)
return
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
ws.close()
return
}
const msg = parsed as BrowserMessage
if (msg.type === 'auth') {
// Authenticate a stdio bridge client without registering it as the
// browser app. This lets the client send request/response messages
// without becoming the RPC target. The token is validated the same
// way as registerBrowser — when auth is disabled (authToken === null),
// any token is accepted.
if (msg.token === null || typeof msg.token === 'string') {
if (!isAuthorized(msg.token, authToken)) {
ws.close()
return
}
authenticatedClients.add(ws)
} else if (msg.token !== undefined) {
ws.close()
}
return
}
if (msg.type === 'register') {
if (msg.token === null || typeof msg.token === 'string') {
registerBrowser(ws, msg.token)
} else if (msg.token !== undefined) {
ws.close()
}
return
}
// All non-register messages require authentication. Without this
// check, an unauthenticated WebSocket client (that hasn't sent a
// valid register message) could bypass the HTTP auth on /rpc by
// sending request messages over the WebSocket directly.
if (!authenticatedClients.has(ws)) {
ws.close()
return
}
if (msg.type === 'request') {
void handleClientRequest(ws, msg)
return
}
if (msg.type === 'response') handleBrowserResponse(msg, ws)
}
function handleClose(ws: WebSocket) {
clients.delete(ws)
authenticatedClients.delete(ws)
if (browserWs !== ws) return
browserWs = null
browserRegistered = false
rejectAllPending('Browser disconnected')
// Intentionally NOT rejecting connectionWaiters here. If a request
// entered waitForConnection() before the close event (e.g. during a
// CLOSING→CLOSED transition), the waiter should keep waiting the full
// APP_WAIT_TIMEOUT for a reconnect. registerBrowser will resolve it
// via notifyConnectionWaiters if the browser reconnects in time.
notifyConnectionChange()
}
function handleConnection(ws: WebSocket) {
if (bridgeClosed) return
clients.add(ws)
// Transport security restricts WHO can connect: Unix socket with
// 0o600 permissions (same-user only) or TCP localhost (any local
// process). The authenticatedClients set gates WHAT connected clients
// can do: only those that sent a valid register message may forward
// requests. The register prompt does NOT include the auth token —
// the browser app sends the token proactively (from the discovery
// file or Vite env).
sendRegisterPrompt(ws)
}
function close() {
bridgeClosed = true
rejectAllPending('Server shutting down')
rejectConnectionWaiters('Server shutting down')
browserWs = null
browserRegistered = false
clients.clear()
authenticatedClients.clear()
connectionListeners.clear()
}
return {
close,
isConnected,
subscribeConnectionChange,
sendRPC,
handleConnection,
handleMessage,
handleClose
}
}