Merge branch 'develop' into server

Conflicts:
  penpot-plugin/src/main.ts
This commit is contained in:
Dominik Jain 2026-01-12 20:32:04 +01:00
commit 7f60e78594
17 changed files with 3527 additions and 913 deletions

View File

@ -12,36 +12,20 @@ Penpot's MCP Server is unlike any other you've seen. You get design-to- design,
## Architecture
The Penpot MCP server exposes tools to AI clients (LLMs), which support the retrieval
The **Penpot MCP Server** exposes tools to AI clients (LLMs), which support the retrieval
of design data as well as the modification and creation of design elements.
The MCP server communicates with Penpot via a dedicated Penpot MCP plugin,
which connects to the MCP server via WebSocket.
The MCP server communicates with Penpot via the dedicated **Penpot MCP Plugin**,
which connects to the MCP server via WebSocket.
This enables the LLM to carry out tasks in the context of a design file by
executing code that leverages the Penpot Plugin API.
The LLM is free to write and execute arbitrary code snippets
within the Penpot Plugin environment to accomplish its tasks.
![Architecture](resources/architecture.png)
This repository is a monorepo containing four main components:
1. **Common Types** (`common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`mcp-server/`):
- Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting
3. **Penpot MCP Plugin** (`penpot-plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe.
This repository thus contains not only the MCP server implementation itself
but also the supporting Penpot MCP Plugin
(see section [Repository Structure](#repository-structure) below).
## Demonstration
@ -180,3 +164,35 @@ of the prompt input area.
To add the Penpot MCP server to a Claude Code project, issue the command
claude mcp add penpot -t http http://localhost:4401/mcp
## Repository Structure
This repository is a monorepo containing four main components:
1. **Common Types** (`common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`mcp-server/`):
- Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting
3. **Penpot MCP Plugin** (`penpot-plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe.
## Beyond Local Execution
The above instructions describe how to run the MCP server and plugin server locally.
We are working on enabling remote deployments of the MCP server, particularly
in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
be able to connect to the same MCP server instance.

41
docs/multi-user-mode.md Normal file
View File

@ -0,0 +1,41 @@
# Multi-User Mode
> [!WARNING]
> Multi-user mode is under development and not yet fully integrated.
> This information is provided for testing purposes only.
The Penpot MCP server supports a multi-user mode, allowing multiple Penpot users
to connect to the same MCP server instance simultaneously.
This supports remote deployments of the MCP server, without requiring each user
to run their own server instance.
## Limitations
Multi-user mode has the limitation that tools which read from or write to
the local file system are not supported, as the server cannot access
the client's file system. This affects the import and export tools.
## Running Components in Multi-User Mode
To run the MCP server and the Penpot MCP plugin in multi-user mode (for testing),
you can use the following command:
```shell
npm run bootstrap:multi-user
```
This will:
* launch the MCP server in multi-user mode (adding the `--multi-user` flag),
* build and launch the Penpot MCP plugin server in multi-user mode.
See the package.json scripts for both `mcp-server` and `penpot-plugin` for details.
In multi-user mode, users are required to be authenticated via a token.
* This token is provided in the URL used to connect to the MCP server,
e.g. `http://localhost:4401/mcp?userToken=USER_TOKEN`.
* The same token must be provided when connecting the Penpot MCP plugin
to the MCP server.
In the future, the token will, most likely be generated by Penpot and
provided to the plugin automatically.
:warning: For now, it is hard-coded in the plugin's source code for testing purposes.

File diff suppressed because it is too large Load Diff

View File

@ -29,9 +29,11 @@ initial_instructions: |
* When a shape is a child of a parent shape, the property `parent` refers to the parent shape, and the read-only properties
`parentX` and `parentY` (as well as `boardX` and `boardY`) provide the position of the shape relative to its parent (containing board).
To position a shape within its parent, set the absolute `x` and `y` properties accordingly.
* The z-order of shapes is, by default, determined by the order in the `children` array of the parent shape.
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later).
To modify z-order after creation, use the following methods on shapes: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based).
# Executing Code

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,9 @@
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"build:full": "npm run build && npm run build:types",
"start": "node dist/index.js",
"dev": "node --loader ts-node/esm src/index.ts"
"start:multi-user": "node dist/index.js --multi-user",
"dev": "node --loader ts-node/esm src/index.ts",
"dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user"
},
"keywords": [
"mcp",

View File

@ -1,4 +1,5 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { AsyncLocalStorage } from "async_hooks";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { ExecuteCodeTool } from "./tools/ExecuteCodeTool";
@ -13,6 +14,13 @@ import { ImportImageTool } from "./tools/ImportImageTool";
import { ReplServer } from "./ReplServer";
import { ApiDocs } from "./ApiDocs";
/**
* Session context for request-scoped data.
*/
export interface SessionContext {
userToken?: string;
}
export class PenpotMcpServer {
private readonly logger = createLogger("PenpotMcpServer");
private readonly server: McpServer;
@ -23,15 +31,21 @@ export class PenpotMcpServer {
private readonly replServer: ReplServer;
private apiDocs: ApiDocs;
/**
* Manages session-specific context, particularly user tokens for each request.
*/
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly transports = {
streamable: {} as Record<string, StreamableHTTPServerTransport>,
sse: {} as Record<string, SSEServerTransport>,
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
};
constructor(
private port: number = 4401,
private webSocketPort: number = 4402,
replPort: number = 4403
replPort: number = 4403,
private isMultiUser: boolean = false
) {
this.configLoader = new ConfigurationLoader();
this.apiDocs = new ApiDocs();
@ -47,26 +61,55 @@ export class PenpotMcpServer {
);
this.tools = new Map<string, Tool<any>>();
this.pluginBridge = new PluginBridge(webSocketPort);
this.pluginBridge = new PluginBridge(this, webSocketPort);
this.replServer = new ReplServer(this.pluginBridge, replPort);
this.registerTools();
}
/**
* Indicates whether the server is running in multi-user mode,
* where user tokens are required for authentication.
*/
public isMultiUserMode(): boolean {
return this.isMultiUser;
}
/**
* Indicates whether file system access is enabled for MCP tools.
* Access is enabled only in single-user mode, where the file system is assumed
* to belong to the user running the server locally.
*/
public isFileSystemAccessEnabled(): boolean {
return !this.isMultiUserMode();
}
public getInitialInstructions(): string {
let instructions = this.configLoader.getInitialInstructions();
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
return instructions;
}
/**
* Retrieves the current session context.
*
* @returns The session context for the current request, or undefined if not in a request context
*/
public getSessionContext(): SessionContext | undefined {
return this.sessionContext.getStore();
}
private registerTools(): void {
// Create relevant tool instances (depending on file system access)
const toolInstances: Tool<any>[] = [
new ExecuteCodeTool(this),
new HighLevelOverviewTool(this),
new PenpotApiInfoTool(this, this.apiDocs),
new ExportShapeTool(this),
new ImportImageTool(this),
new ExportShapeTool(this), // tool adapts to file system access internally
];
if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this));
}
for (const tool of toolInstances) {
const toolName = tool.getToolName();
@ -88,51 +131,70 @@ export class PenpotMcpServer {
}
private setupHttpEndpoints(): void {
/**
* Modern Streamable HTTP connection endpoint
*/
this.app.all("/mcp", async (req: any, res: any) => {
const { randomUUID } = await import("node:crypto");
const userToken = req.query.userToken as string | undefined;
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
await this.sessionContext.run({ userToken }, async () => {
const { randomUUID } = await import("node:crypto");
if (sessionId && this.transports.streamable[sessionId]) {
transport = this.transports.streamable[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
this.transports.streamable[id] = transport;
},
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && this.transports.streamable[sessionId]) {
transport = this.transports.streamable[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
this.transports.streamable[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
await this.server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
});
});
/**
* Legacy SSE connection endpoint
*/
this.app.get("/sse", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined;
await this.sessionContext.run({ userToken }, async () => {
const transport = new SSEServerTransport("/messages", res);
this.transports.sse[transport.sessionId] = { transport, userToken };
res.on("close", () => {
delete this.transports.sse[transport.sessionId];
});
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
await this.server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
});
this.app.get("/sse", async (_req: any, res: any) => {
const transport = new SSEServerTransport("/messages", res);
this.transports.sse[transport.sessionId] = transport;
res.on("close", () => {
delete this.transports.sse[transport.sessionId];
});
await this.server.connect(transport);
});
/**
* SSE message POST endpoint (using previously established session)
*/
this.app.post("/messages", async (req: any, res: any) => {
const sessionId = req.query.sessionId as string;
const transport = this.transports.sse[sessionId];
const session = this.transports.sse[sessionId];
if (transport) {
await transport.handlePostMessage(req, res, req.body);
if (session) {
await this.sessionContext.run({ userToken: session.userToken }, async () => {
await session.transport.handlePostMessage(req, res, req.body);
});
} else {
res.status(400).send("No transport found for sessionId");
}

View File

@ -1,19 +1,29 @@
import { WebSocket, WebSocketServer } from "ws";
import * as http from "http";
import { PluginTask } from "./PluginTask";
import { PluginTaskResponse, PluginTaskResult } from "@penpot-mcp/common";
import { createLogger } from "./logger";
import type { PenpotMcpServer } from "./PenpotMcpServer";
interface ClientConnection {
socket: WebSocket;
userToken: string | null;
}
/**
* Provides the connection to the Penpot MCP Plugin via WebSocket
* Manages WebSocket connections to Penpot plugin instances and handles plugin tasks
* over these connections.
*/
export class PluginBridge {
private readonly logger = createLogger("PluginBridge");
private readonly wsServer: WebSocketServer;
private readonly connectedClients: Set<WebSocket> = new Set();
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
private readonly pendingTasks: Map<string, PluginTask<any, any>> = new Map();
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map();
constructor(
private mcpServer: PenpotMcpServer,
private port: number,
private taskTimeoutSecs: number = 30
) {
@ -25,12 +35,39 @@ export class PluginBridge {
* Sets up WebSocket connection handlers for plugin communication.
*
* Manages client connections and provides bidirectional communication
* channel between the MCP server and Penpot plugin instances.
* channel between the MCP mcpServer and Penpot plugin instances.
*/
private setupWebSocketHandlers(): void {
this.wsServer.on("connection", (ws: WebSocket) => {
this.logger.info("New WebSocket connection established");
this.connectedClients.add(ws);
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 (authenticated)");
} else {
this.logger.info("New WebSocket connection established (unauthenticated)");
}
// register the client connection with both indexes
const connection: ClientConnection = { socket: ws, userToken };
this.connectedClients.set(ws, connection);
if (userToken) {
// ensure only one connection per userToken
if (this.clientsByToken.has(userToken)) {
this.logger.warn("Duplicate connection for given user token; rejecting new connection");
ws.close(1008, "Duplicate connection for given user token; close previous connection first.");
}
this.clientsByToken.set(userToken, connection);
}
ws.on("message", (data: Buffer) => {
this.logger.info("Received WebSocket message: %s", data.toString());
@ -44,16 +81,24 @@ export class PluginBridge {
ws.on("close", () => {
this.logger.info("WebSocket connection closed");
const connection = this.connectedClients.get(ws);
this.connectedClients.delete(ws);
if (connection?.userToken) {
this.clientsByToken.delete(connection.userToken);
}
});
ws.on("error", (error) => {
this.logger.error(error, "WebSocket connection error");
const connection = this.connectedClients.get(ws);
this.connectedClients.delete(ws);
if (connection?.userToken) {
this.clientsByToken.delete(connection.userToken);
}
});
});
this.logger.info("WebSocket server started on port %d", this.port);
this.logger.info("WebSocket mcpServer started on port %d", this.port);
}
/**
@ -90,6 +135,50 @@ export class PluginBridge {
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
}
/**
* Determines the client connection to use for executing a task.
*
* In single-user mode, returns the single connected client.
* In multi-user mode, returns the client matching the session's userToken.
*
* @returns The client connection to use
* @throws Error if no suitable connection is found or if configuration is invalid
*/
private getClientConnection(): ClientConnection {
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.");
}
const connection = this.clientsByToken.get(sessionContext.userToken);
if (!connection) {
throw new Error(
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
);
}
return connection;
} else {
// single-user mode: return the single connected client
if (this.connectedClients.size === 0) {
throw new Error(
`No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`
);
}
if (this.connectedClients.size > 1) {
throw new Error(
`Multiple (${this.connectedClients.size}) Penpot MCP Plugin instances are connected. ` +
`Ask the user to ensure that only one instance is connected at a time.`
);
}
// return the first (and only) connection
const connection = this.connectedClients.values().next().value;
return <ClientConnection>connection;
}
}
/**
* Executes a plugin task by sending it to connected clients.
*
@ -102,44 +191,22 @@ export class PluginBridge {
public async executePluginTask<TResult extends PluginTaskResult<any>>(
task: PluginTask<any, TResult>
): Promise<TResult> {
// Check for a single connected client
if (this.connectedClients.size === 0) {
throw new Error(
`No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`
);
}
if (this.connectedClients.size > 1) {
throw new Error(
`Multiple (${this.connectedClients.size}) Penpot MCP Plugin instances are connected. ` +
`Ask the user to ensure that only one instance is connected at a time.`
);
}
// get the appropriate client connection based on mode
const connection = this.getClientConnection();
// Register the task for result correlation
// register the task for result correlation
this.pendingTasks.set(task.id, task);
// Send task to all connected clients
// send task to the selected client
const requestMessage = JSON.stringify(task.toRequest());
let sentCount = 0;
this.connectedClients.forEach((client) => {
if (client.readyState === 1) {
// WebSocket.OPEN
client.send(requestMessage);
sentCount++;
}
});
if (sentCount === 0) {
// Clean up the pending task and timeout since we couldn't send it
if (connection.socket.readyState !== 1) {
// WebSocket is not open
this.pendingTasks.delete(task.id);
const timeoutHandle = this.taskTimeouts.get(task.id);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
this.taskTimeouts.delete(task.id);
}
throw new Error(`All connected plugin instances appear to be disconnected. Task could not be sent.`);
throw new Error(`Plugin instance is disconnected. Task could not be sent.`);
}
connection.socket.send(requestMessage);
// Set up a timeout to reject the task if no response is received
const timeoutHandle = setTimeout(() => {
const pendingTask = this.pendingTasks.get(task.id);
@ -153,7 +220,7 @@ export class PluginBridge {
}, this.taskTimeoutSecs * 1000);
this.taskTimeouts.set(task.id, timeoutHandle);
this.logger.info(`Sent task ${task.id} to ${sentCount} connected client`);
this.logger.info(`Sent task ${task.id} to connected client`);
return await task.getResultPromise();
}

View File

@ -1,7 +1,7 @@
import { z } from "zod";
import "reflect-metadata";
import { TextResponse, ToolResponse } from "./ToolResponse";
import type { PenpotMcpServer } from "./PenpotMcpServer";
import type { PenpotMcpServer, SessionContext } from "./PenpotMcpServer";
import { createLogger } from "./logger";
/**
@ -38,6 +38,10 @@ export abstract class Tool<TArgs extends object> {
let argsInstance: TArgs = args as TArgs;
this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance));
// TODO: Remove; testing only
const sessionContext = this.mcpServer.getSessionContext();
this.logger.info("Session context: %s", sessionContext ? JSON.stringify(sessionContext) : "none");
// execute the actual tool logic
let result = await this.executeCore(argsInstance);
@ -89,6 +93,15 @@ export abstract class Tool<TArgs extends object> {
return formatted.length > 0 ? "\n" + formatted.join("\n") : "{}";
}
/**
* Retrieves the current session context.
*
* @returns The session context for the current request, or undefined if not in a request context
*/
protected getSessionContext(): SessionContext | undefined {
return this.mcpServer.getSessionContext();
}
public getInputSchema() {
return this.inputSchema;
}

View File

@ -22,6 +22,7 @@ async function main(): Promise<void> {
try {
const args = process.argv.slice(2);
let port = 4401; // default port
let multiUser = false; // default to single-user mode
// parse command line arguments
for (let i = 0; i < args.length; i++) {
@ -42,18 +43,21 @@ async function main(): Promise<void> {
if (i + 1 < args.length) {
process.env.LOG_DIR = args[i + 1];
}
} else if (args[i] === "--multi-user") {
multiUser = true;
} else if (args[i] === "--help" || args[i] === "-h") {
logger.info("Usage: node dist/index.js [options]");
logger.info("Options:");
logger.info(" --port, -p <number> Port number for the HTTP/SSE server (default: 4401)");
logger.info(" --log-level, -l <level> Log level: trace, debug, info, warn, error (default: info)");
logger.info(" --log-dir <path> Directory for log files (default: mcp-server/logs)");
logger.info(" --multi-user Enable multi-user mode (default: single-user)");
logger.info(" --help, -h Show this help message");
process.exit(0);
}
}
const server = new PenpotMcpServer(port);
const server = new PenpotMcpServer(port, undefined, undefined, multiUser);
await server.start();
// keep the process alive

View File

@ -45,7 +45,13 @@ export class ExportShapeTool extends Tool<ExportShapeArgs> {
* @param mcpServer - The MCP server instance
*/
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, ExportShapeArgs.schema);
let schema: any = ExportShapeArgs.schema;
if (!mcpServer.isFileSystemAccessEnabled()) {
// remove filePath key from schema
schema = { ...schema };
delete schema.filePath;
}
super(mcpServer, schema);
}
public getToolName(): string {
@ -53,11 +59,11 @@ export class ExportShapeTool extends Tool<ExportShapeArgs> {
}
public getToolDescription(): string {
return (
let description =
"Exports a shape from the Penpot design to a PNG or SVG image, " +
"such that you can get an impression of what the shape looks like.\n" +
"Alternatively, you can save it to a file."
);
"such that you can get an impression of what the shape looks like.";
if (this.mcpServer.isFileSystemAccessEnabled()) description += "\nAlternatively, you can save it to a file.";
return description;
}
protected async executeCore(args: ExportShapeArgs): Promise<ToolResponse> {
@ -89,6 +95,10 @@ export class ExportShapeTool extends Tool<ExportShapeArgs> {
return TextResponse.fromData(imageData);
}
} else {
// make sure file system access is enabled
if (!this.mcpServer.isFileSystemAccessEnabled()) {
throw new Error("File system access is not enabled on the MCP server!");
}
// save image to file
if (args.format === "png") {
FileUtils.writeBinaryFile(args.filePath, PNGImageContent.byteData(imageData));

View File

@ -5,8 +5,11 @@
"scripts": {
"install:all": "concurrently --names \"COMMON,MCP-SERVER,PLUGIN\" --prefix-colors \"green,cyan,magenta\" \"npm --prefix common install\" \"npm --prefix mcp-server install\" \"npm --prefix penpot-plugin install\"",
"build:all": "concurrently --names \"COMMON,MCP-SERVER,PLUGIN\" --prefix-colors \"green,cyan,magenta\" --success first \"npm --prefix common install && npm --prefix common run build\" \"npm --prefix mcp-server run build\" \"npm --prefix penpot-plugin run build\"",
"build:all-multi-user": "concurrently --names \"COMMON,MCP-SERVER,PLUGIN\" --prefix-colors \"green,cyan,magenta\" --success first \"npm --prefix common install && npm --prefix common run build\" \"npm --prefix mcp-server run build\" \"npm --prefix penpot-plugin run build:multi-user\"",
"start:all": "concurrently --names \"MCP-SERVER,PLUGIN-SERVER\" --prefix-colors \"cyan,magenta\" --kill-others-on-fail \"npm --prefix mcp-server start\" \"npm --prefix penpot-plugin run dev\"",
"start:all-multi-user": "concurrently --names \"MCP-SERVER,PLUGIN-SERVER\" --prefix-colors \"cyan,magenta\" --kill-others-on-fail \"npm --prefix mcp-server run start:multi-user\" \"npm --prefix penpot-plugin run dev:multi-user\"",
"bootstrap": "npm run install:all && npm run build:all && npm run start:all",
"bootstrap:multi-user": "npm run install:all && npm run build:all-multi-user && npm run start:all-multi-user",
"format": "prettier --write .",
"format:check": "prettier --check ."
},

View File

@ -1,12 +1,12 @@
{
"name": "penpot-plugin-starter-template",
"version": "0.0.0",
"name": "penpot-mcp-plugin",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "penpot-plugin-starter-template",
"version": "0.0.0",
"name": "penpot-mcp-plugin",
"version": "1.0.0",
"dependencies": {
"@penpot-mcp/common": "file:../common",
"@penpot/plugin-styles": "1.3.2",
@ -14,6 +14,7 @@
"penpot-mcp": "file:.."
},
"devDependencies": {
"cross-env": "^7.0.3",
"typescript": "^5.8.3",
"vite": "^7.0.5",
"vite-live-preview": "^0.3.2"
@ -816,6 +817,40 @@
"node": ">=18"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@ -914,6 +949,13 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -950,6 +992,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/penpot-mcp": {
"resolved": "..",
"link": true
@ -1039,6 +1091,29 @@
"fsevents": "~2.3.2"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1184,6 +1259,22 @@
"vite": ">=5.2.13"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",

View File

@ -1,11 +1,13 @@
{
"name": "penpot-plugin-starter-template",
"name": "penpot-mcp-plugin",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "tsc && vite build"
"dev:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch",
"build": "tsc && vite build",
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build"
},
"dependencies": {
"@penpot-mcp/common": "file:../common",
@ -14,6 +16,7 @@
"penpot-mcp": "file:.."
},
"devDependencies": {
"cross-env": "^7.0.3",
"typescript": "^5.8.3",
"vite": "^7.0.5",
"vite-live-preview": "^0.3.2"

View File

@ -4,16 +4,25 @@ import "./style.css";
const searchParams = new URLSearchParams(window.location.search);
document.body.dataset.theme = searchParams.get("theme") ?? "light";
// Determine whether multi-user mode is enabled based on URL parameters
const isMultiUserMode = searchParams.get("multiUser") === "true";
console.log("Penpot MCP multi-user mode:", isMultiUserMode);
// WebSocket connection management
let ws: WebSocket | null = null;
const statusElement = document.getElementById("connection-status");
/**
* Updates the connection status display element.
*
* @param status - the base status text to display
* @param isConnectedState - whether the connection is in a connected state (affects color)
* @param message - optional additional message to append to the status
*/
function updateConnectionStatus(status: string, isConnectedState: boolean): void {
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
if (statusElement) {
statusElement.textContent = status;
const displayText = message ? `${status}: ${message}` : status;
statusElement.textContent = displayText;
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
}
}
@ -42,9 +51,13 @@ function connectToMcpServer(): void {
}
try {
// Use environment variable for MCP WebSocket URL, fallback to localhost for local development
const mcpWsUrl = import.meta.env.PENPOT_MCP_WEBSOCKET_URL || "ws://localhost:4402";
ws = new WebSocket(mcpWsUrl);
let wsUrl = import.meta.env.PENPOT_MCP_WEBSOCKET_URL || "ws://localhost:4402";
if (isMultiUserMode) {
// TODO obtain proper userToken from penpot
const userToken = "dummyToken";
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
}
ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false);
ws.onopen = () => {
@ -63,19 +76,22 @@ function connectToMcpServer(): void {
}
};
ws.onclose = () => {
ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server");
updateConnectionStatus("Disconnected", false);
const message = event.reason || undefined;
updateConnectionStatus("Disconnected", false, message);
ws = null;
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
// note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false);
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
updateConnectionStatus("Connection failed", false);
const message = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message);
}
}

View File

@ -6,10 +6,16 @@ import { Task, TaskHandler } from "./TaskHandler";
*/
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { width: 158, height: 200 });
// Determine whether multi-user mode is enabled based on build-time configuration
declare const IS_MULTI_USER_MODE: boolean;
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
// Handle both legacy string messages and new request-based messages
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
// Handle messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
// Handle plugin task requests
if (typeof message === "object" && message.task && message.id) {
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
@ -53,7 +59,7 @@ async function handlePluginTaskRequest(request: { id: string; task: string; para
}
}
// Update the theme in the iframe
// Handle theme change in the iframe
penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({
source: "penpot",

View File

@ -1,6 +1,10 @@
import { defineConfig } from "vite";
import livePreview from "vite-live-preview";
// Debug: Log the environment variable
console.log("MULTI_USER_MODE env:", process.env.MULTI_USER_MODE);
console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(process.env.MULTI_USER_MODE === "true"));
export default defineConfig({
plugins: [
livePreview({
@ -30,4 +34,7 @@ export default defineConfig({
? process.env.PENPOT_MCP_PLUGIN_SERVER_ALLOWED_HOSTS.split(",").map((h) => h.trim())
: [],
},
define: {
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
},
});