mirror of
https://github.com/penpot/penpot-mcp.git
synced 2026-04-25 11:18:37 +00:00
Merge branch 'develop' into server
Conflicts: penpot-plugin/src/main.ts
This commit is contained in:
commit
7f60e78594
68
README.md
68
README.md
@ -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.
|
||||
|
||||

|
||||
|
||||
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
41
docs/multi-user-mode.md
Normal 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
@ -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
|
||||
|
||||
|
||||
1118
mcp-server/package-lock.json
generated
1118
mcp-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 ."
|
||||
},
|
||||
|
||||
99
penpot-plugin/package-lock.json
generated
99
penpot-plugin/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user