penpot/mcp/packages/server/src/PluginBridge.ts
Dominik Jain be17fabc6a 🎉 Introduce global plugins and move MCP to application scope
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
  across dashboard and workspace navigation within one browser tab.
  Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
  loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
  cleanup of listeners, timers and UI resources. Global scope does not
  imply automatic startup or persistence across a browser reload.

Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
  workspace, while user, theme and UI facilities remain available on
  the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
  none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
  defaulting to the current team and resolving only once the requested
  file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
  exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
  return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
  reacquire file, page and shape references after changing files.

MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
  authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
  well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
  and each tab connects explicitly through dashboard or workspace
  controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
  during navigation; disconnect on request and close on disable/logout.

MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
  excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
  to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
  the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
  not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
  navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.

Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
  readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
  that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
  builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
  still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
  document or iframe reload, preserving facade identity and storage.

AI-assisted-by: gpt-6
2026-10-07 14:58:32 +02:00

493 lines
21 KiB
TypeScript

import { WebSocket, WebSocketServer } from "ws";
import * as http from "http";
import { z } from "zod";
import { AbstractPluginTask, PluginTask } from "./PluginTask";
import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common";
import { createLogger } from "./logger";
import type { PenpotMcpServer } from "./PenpotMcpServer";
import type { RedisBridge } from "./RedisBridge";
import type { PenpotConnection, PluginLivenessState } from "./PenpotConnection";
import { UserPenpotConnections } from "./UserPenpotConnections";
import type { TaskDispatcher, TaskDispatchHost } from "./dispatch/TaskDispatcher";
import { SingleInstanceTaskDispatcher } from "./dispatch/SingleInstanceTaskDispatcher";
import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatcher";
const KEEP_ALIVE_TIME = 30000; // 30 seconds
const INITIALIZATION_TIMEOUT_MS = 10_000;
/**
* Maximum plugin heartbeat age before a connection is stale.
*
* This uses plugin heartbeats rather than WebSocket pongs because the browser can answer
* protocol pings while the tab's JavaScript event loop is frozen.
*/
export const HEARTBEAT_STALE_THRESHOLD_MS = 30000;
const connectionInitSchema = z.object({
type: z.literal("initialize"),
session: z.object({
sessionId: z.string().min(1).max(128),
fileId: z.string().min(1).max(128).nullable(),
fileName: z.string().nullable(),
workspaceState: z.enum(["none", "loading", "ready"]).optional(),
}),
});
/**
* Throws if the plugin tab cannot currently run tasks.
*
* A socket can stay open while the page event loop is paused, so task dispatch must check
* plugin-level liveness before sending work.
*/
export function assertPluginResponsive(
state: PluginLivenessState,
now: number,
staleThresholdMs: number = HEARTBEAT_STALE_THRESHOLD_MS
): void {
if (state.frozen) {
throw new Error(
`The Penpot plugin tab has been frozen by the browser and cannot run tasks. ` +
`Please click/focus the Penpot tab to wake it, then retry.`
);
}
const heartbeatAge = now - state.lastHeartbeat;
if (heartbeatAge > staleThresholdMs) {
throw new Error(
`The Penpot plugin tab appears to be suspended by the browser (no heartbeat for ` +
`${Math.round(heartbeatAge / 1000)}s). Please click/focus the Penpot tab to wake it, ` +
`then retry.`
);
}
}
/**
* Manages WebSocket connections to Penpot plugin instances and handles plugin tasks
* over these connections.
*/
export class PluginBridge implements TaskDispatchHost {
public static readonly MULTIUSER_CONNECTION_ERROR_MESSAGE = `No Penpot instance connected for user token. Please ensure that Penpot is connected and that the MCP client connection is using the correct token.`;
private readonly logger = createLogger("PluginBridge");
private readonly wsServer: WebSocketServer;
private readonly connectionsBySocket: Map<WebSocket, PenpotConnection> = new Map();
private readonly userPenpotConnectionsByToken: Map<string, UserPenpotConnections> = new Map();
private readonly singleUserConnections = new UserPenpotConnections();
private readonly pendingTasks: Map<string, AbstractPluginTask<any, any>> = new Map();
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map();
private readonly taskConnections: Map<string, PenpotConnection> = new Map();
private readonly dispatcher: TaskDispatcher;
/**
* Creates the plugin bridge and starts its WebSocket server.
*
* @param mcpServer - The owning MCP server
* @param port - The port on which to listen for plugin WebSocket connections
* @param redisBridge - Optional Redis bridge enabling multi-instance task routing.
* When provided, tasks handled by this instance are routed to the instance
* holding the relevant plugin's WebSocket connection (which may be this same
* instance) via Redis, rather than dispatched directly over a local socket.
* @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution
* (defaults to {@link DEFAULT_TASK_TIMEOUT_SECS})
*/
constructor(
public readonly mcpServer: PenpotMcpServer,
private port: number,
private readonly taskTimeoutSecs: number,
private readonly redisBridge?: RedisBridge
) {
this.dispatcher = redisBridge
? new MultiInstanceTaskDispatcher(this, redisBridge)
: new SingleInstanceTaskDispatcher(this);
this.wsServer = new WebSocketServer({ port: port, host: mcpServer.host });
this.setupWebSocketHandlers();
}
/**
* Sets up WebSocket connection handlers for plugin communication.
*
* Manages client connections and provides bidirectional communication
* channel between the MCP mcpServer and Penpot plugin instances.
*/
private setupWebSocketHandlers(): void {
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
// extract userToken from query parameters
const url = new URL(request.url!, `ws://${request.headers.host}`);
const userToken = url.searchParams.get("userToken");
// require userToken if running in multi-user mode
if (this.mcpServer.isMultiUserMode() && !userToken) {
this.logger.warn("Connection attempt without userToken in multi-user mode - rejecting");
ws.close(1008, "Missing userToken parameter");
return;
}
if (userToken) {
this.logger.info("New WebSocket connection established (token provided)");
} else {
this.logger.info("New WebSocket connection established");
}
// require metadata before registering a connection for dispatch
const initializationTimeout = setTimeout(() => {
ws.close(1008, "Connection initialization timed out; please update the Penpot MCP plugin.");
}, INITIALIZATION_TIMEOUT_MS);
let initializing = false;
ws.on("message", (data: Buffer) => {
this.logger.debug("Received WebSocket message: %s", data.toString());
try {
const message = JSON.parse(data.toString());
const connection = this.connectionsBySocket.get(ws);
if (!connection) {
if (initializing) {
return;
}
const initialization = connectionInitSchema.safeParse(message);
if (!initialization.success) {
ws.close(1008, "Expected connection metadata; please update the Penpot MCP plugin.");
return;
}
initializing = true;
void this.initializeConnection(
ws,
this.mcpServer.isMultiUserMode() ? userToken : null,
initialization.data.session
)
.then(() => clearTimeout(initializationTimeout))
.catch((error) => {
this.logger.error(error, "Failed to initialize Penpot connection");
clearTimeout(initializationTimeout);
this.removeConnection(ws);
ws.close(1011, "Failed to initialize Penpot connection; please retry.");
});
return;
}
// any plugin message proves the page event loop is running
connection.lastHeartbeat = Date.now();
if (message?.type === "freeze") {
connection.frozen = true;
this.logger.info("Plugin tab reported it is being frozen by the browser");
return;
}
connection.frozen = false;
if (message?.type === "context-update") {
const context = connectionInitSchema.shape.session
.omit({ sessionId: true })
.parse(message.context);
Object.assign(connection.session, context);
return;
}
if (message?.type === "heartbeat") {
return;
}
this.handlePluginTaskResponse(message as PluginTaskResponse<any>, connection);
} catch (error) {
this.logger.error(error, "Failure while processing WebSocket message");
}
});
ws.on("close", () => {
this.logger.info("WebSocket connection closed");
clearTimeout(initializationTimeout);
this.removeConnection(ws);
});
ws.on("error", (error) => {
this.logger.error(error, "WebSocket connection error");
clearTimeout(initializationTimeout);
this.removeConnection(ws);
ws.terminate();
});
});
this.logger.info("WebSocket mcpServer started on port %d", this.port);
}
private async initializeConnection(
ws: WebSocket,
userToken: string | null,
session: PenpotConnection["session"]
): Promise<void> {
let connections = this.getUserConnections(userToken);
if (!connections) {
connections = new UserPenpotConnections();
this.userPenpotConnectionsByToken.set(userToken!, connections);
}
// start the per-connection keep-alive ping interval
const pingInterval = setInterval(() => {
ws.ping();
}, KEEP_ALIVE_TIME);
// register the client connection with both indexes
const connection: PenpotConnection = {
socket: ws,
userToken,
pingInterval,
session,
ready: false,
lastHeartbeat: Date.now(),
frozen: false,
};
// the same tab reconnecting reuses its session ID while its previous socket may still be half-open
const replaced = connections.add(connection);
this.connectionsBySocket.set(ws, connection);
if (replaced) {
this.displaceConnection(replaced);
}
await this.dispatcher.onNewConnection(connection);
// disconnect may have removed this connection while subscriptions were pending
if (ws.readyState === WebSocket.OPEN && this.connectionsBySocket.get(ws) === connection) {
connection.ready = true;
ws.send(JSON.stringify({ type: "initialized" }));
}
}
/**
* Closes a connection superseded by a newer connection for the same session.
*
* Uses close code 1008 so that a still-running plugin behind the old socket stops
* reconnecting instead of competing with the newer connection for the session.
*
* @param connection - The superseded connection
*/
public displaceConnection(connection: PenpotConnection): void {
this.logger.info("Closing connection replaced by a newer connection for the same session");
this.removeConnection(connection.socket);
connection.socket.close(1008, "Replaced by a newer connection for this Penpot session.");
}
/**
* Removes a client connection and releases all resources associated with it.
*
* Clears the per-connection keep-alive interval and removes the connection from the
* socket-keyed index and the user's session collection. The token-keyed index entry
* is removed only when the user's last local connection closes. Safe to call with a
* socket that is not (or no longer) registered.
*
* @param ws - The WebSocket whose connection state should be removed
*/
private removeConnection(ws: WebSocket): void {
const connection = this.connectionsBySocket.get(ws);
if (!connection) {
return;
}
connection.ready = false;
clearInterval(connection.pingInterval);
this.connectionsBySocket.delete(ws);
const connections = this.getUserConnections(connection.userToken)!;
connections.remove(connection);
if (connection.userToken !== null && connections.size === 0) {
this.userPenpotConnectionsByToken.delete(connection.userToken);
}
void this.dispatcher
.onConnectionClosed(connection)
.catch((error) => this.logger.error(error, "Failed to remove connection subscriptions"));
}
/**
* Handles responses from the plugin for completed tasks.
*
* Finds the pending task by ID and resolves or rejects its promise
* based on the execution result.
*
* @param response - The plugin task response containing ID and result
* @param connection - The responding local connection, or undefined for Redis responses
*/
private handlePluginTaskResponse(response: PluginTaskResponse<any>, connection?: PenpotConnection): void {
const task = this.pendingTasks.get(response.id);
if (!task) {
this.logger.info(`Received response for unknown task ID: ${response.id}`);
return;
}
if (this.taskConnections.get(response.id) !== connection) {
return;
}
// Clear the timeout and remove the task from pending tasks
const timeoutHandle = this.taskTimeouts.get(response.id);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
this.taskTimeouts.delete(response.id);
}
this.pendingTasks.delete(response.id);
this.taskConnections.delete(response.id);
// Resolve or reject the task's promise based on the result
if (response.success) {
task.resolveWithResult({ data: response.data });
} else {
const error = new Error(response.error || "Task execution failed (details not provided)");
task.rejectWithError(error);
}
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
}
/**
* Rejects a still-pending task with the given error, releasing its correlation state.
*
* Clears the task's timeout (if armed) and removes the task from the pending-task
* index before rejecting its promise. Safe to call for a task that has already been
* settled (e.g. by a response or a timeout), in which case nothing happens.
*
* @param taskId - The ID of the task to reject
* @param error - The error with which to reject the task
* @returns Whether the task was still pending and has been rejected
*/
private rejectPendingTask(taskId: string, error: Error): boolean {
const pendingTask = this.pendingTasks.get(taskId);
if (!pendingTask) {
return false;
}
const timeoutHandle = this.taskTimeouts.get(taskId);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
this.taskTimeouts.delete(taskId);
}
this.pendingTasks.delete(taskId);
this.taskConnections.delete(taskId);
pendingTask.rejectWithError(error);
this.logger.info(`Task ${taskId} rejected: ${error.message}`);
return true;
}
getUserConnections(userToken: string | null): UserPenpotConnections | undefined {
return userToken === null ? this.singleUserConnections : this.userPenpotConnectionsByToken.get(userToken);
}
/**
* Executes a plugin task by sending it to the connected Penpot plugin instance,
* either directly via WebSocket or indirectly via Redis (depending on the configuration),
* and awaiting the result.
*
* @param task - The plugin task to execute
* @param sessionId - The target session; when omitted, discovery must yield exactly one session
* @throws Error if no plugin instances are connected or available
*/
public async executePluginTask<TResult extends PluginTaskResult<any>>(
task: PluginTask<any, TResult>,
sessionId?: string
): Promise<TResult> {
let userToken: string | null = null;
if (this.mcpServer.isMultiUserMode()) {
const sessionContext = this.mcpServer.getSessionContext();
if (!sessionContext?.userToken) {
throw new Error("No userToken found in session context. Multi-user mode requires authentication.");
}
userToken = sessionContext.userToken;
}
// attach the result handler before dispatch can reject the task
const result = task.getResultPromise();
void this.dispatcher.dispatch(task, userToken, sessionId).catch((error) => {
task.rejectWithError(error instanceof Error ? error : new Error(String(error)));
});
return await result;
}
/**
* Registers a task for response correlation, sends its request over Redis,
* and arms a timeout that rejects the task if no response is received.
*
* The response (whether arriving over the local WebSocket or over Redis) is later
* matched by ID in {@link handlePluginTaskResponse}, which settles the task via its
* `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout
* handling therefore applies regardless of the transport.
*
* When routing via Redis, the task is rejected immediately (rather than timing out)
* if the published request reached no instance, i.e. if no instance holds a plugin
* connection for the user's session, or if publishing fails outright.
*
* @param task - The task to dispatch
* @param userToken - The user token identifying the target session's owner
* @param sessionId - The target Penpot session
*/
public sendRemoteTask(task: AbstractPluginTask, userToken: string, sessionId: string): void {
const redisBridge = this.redisBridge!;
this.logger.debug("Dispatching task %s via Redis", task.id);
// register the task for result correlation, then publish the request via Redis
this.pendingTasks.set(task.id, task);
void redisBridge
.sendTaskRequest(userToken, sessionId, task.toRequest(), (response) =>
this.handlePluginTaskResponse(response)
)
.then((receiverCount) => {
// fail fast when no instance received the request (no connection with matching user token and session ID in any instance)
if (receiverCount === 0) {
this.rejectPendingTask(
task.id,
new Error(`Penpot session ${JSON.stringify(sessionId)} is not connected for this user.`)
);
}
})
.catch((error) => {
this.rejectPendingTask(task.id, error instanceof Error ? error : new Error(String(error)));
});
// on timeout, release the response-channel subscription, since no response
// will arrive to trigger its self-unsubscribe.
this.startTaskTimeout(task, () => void redisBridge.unsubscribeFromResponse(task.id));
}
/** Sends a task directly to a ready local connection, with execution tracking. */
public sendLocalTask(task: AbstractPluginTask, connection: PenpotConnection): void {
const target = connection;
if (!target.ready || target.socket.readyState !== 1) {
// WebSocket is not open
throw new Error(`Plugin instance is disconnected. Task could not be sent.`);
}
// the socket can be open while browser-throttled plugin JS cannot run tasks
assertPluginResponsive(target, Date.now());
// register the task for result correlation, then send over the socket
this.pendingTasks.set(task.id, task);
this.taskConnections.set(task.id, target);
target.socket.send(JSON.stringify(task.toRequest()));
this.startTaskTimeout(task);
}
/** Arms the shared execution timeout for a locally or remotely dispatched task. */
private startTaskTimeout(task: AbstractPluginTask<any, any>, onTimeout?: () => void): void {
// Set up a timeout to reject the task if no response is received
const timeoutHandle = setTimeout(() => {
if (
this.rejectPendingTask(
task.id,
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
)
) {
onTimeout?.();
}
}, this.taskTimeoutSecs * 1000);
this.taskTimeouts.set(task.id, timeoutHandle);
this.logger.info(`Sent task ${task.id}`);
}
/**
* Closes the WebSocket server and all connected client sockets.
* Also releases task tracking and dispatch subscriptions.
*/
public async close(): Promise<void> {
for (const socket of this.wsServer.clients) {
this.removeConnection(socket);
socket.terminate();
}
for (const taskId of this.pendingTasks.keys()) {
this.rejectPendingTask(taskId, new Error("MCP server is shutting down."));
}
await this.dispatcher.close();
return new Promise((resolve) => {
this.wsServer.close(() => {
this.logger.info("WebSocket server closed");
resolve();
});
});
}
}