Merge pull request #23

This commit is contained in:
Dominik Jain 2026-01-12 22:59:37 +01:00 committed by GitHub
commit b27ff03fbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 102 additions and 46 deletions

View File

@ -190,9 +190,50 @@ This repository is a monorepo containing four main components:
The core components are written in TypeScript, rendering interactions with the The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe. Penpot Plugin API both natural and type-safe.
## Configuration
The Penpot MCP server can be configured using environment variables. All configuration
options use the `PENPOT_MCP_` prefix for consistency.
### Server Configuration
| Environment Variable | Description | Default |
|-----------------------------|------------- --------------------------------------------------------------|---------|
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address where the MCP server can be reached | `localhost` |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
### Logging Configuration
| Environment Variable | Description | Default |
|------------------------|------------------------------------------------------|---------|
| `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` |
| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` |
### Plugin Server Configuration
| Environment Variable | Description | Default |
|-------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens. Can be a single address or a comma-separated list. For example, use `0.0.0.0` to accept connections from any address (use caution in untrusted networks). | (local only) |
## Beyond Local Execution ## Beyond Local Execution
The above instructions describe how to run the MCP server and plugin server locally. 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 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 in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
be able to connect to the same MCP server instance. be able to connect to the same MCP server instance.
To run the server remotely (even for a single user),
you may set the following environment variables to configure the two servers
(MCP server & plugin server) appropriately:
* `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating
in remote mode, with local file system access disabled.
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).
* `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS`: Set this to the address (or a
comma-separated list of addresses) on which the plugin web server listens.
To accept connections from any address, use `0.0.0.0` (use caution in
untrusted networks).

View File

@ -41,12 +41,18 @@ export class PenpotMcpServer {
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>, sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
}; };
constructor( private readonly port: number;
private port: number = 4401, private readonly webSocketPort: number;
private webSocketPort: number = 4402, private readonly replPort: number;
replPort: number = 4403, public readonly serverAddress: string;
private isMultiUser: boolean = false
) { constructor(private isMultiUser: boolean = false) {
// read port configuration from environment variables
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
this.serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS ?? "localhost";
this.configLoader = new ConfigurationLoader(); this.configLoader = new ConfigurationLoader();
this.apiDocs = new ApiDocs(); this.apiDocs = new ApiDocs();
@ -61,8 +67,8 @@ export class PenpotMcpServer {
); );
this.tools = new Map<string, Tool<any>>(); this.tools = new Map<string, Tool<any>>();
this.pluginBridge = new PluginBridge(this, webSocketPort); this.pluginBridge = new PluginBridge(this, this.webSocketPort);
this.replServer = new ReplServer(this.pluginBridge, replPort); this.replServer = new ReplServer(this.pluginBridge, this.replPort);
this.registerTools(); this.registerTools();
} }
@ -75,13 +81,27 @@ export class PenpotMcpServer {
return this.isMultiUser; return this.isMultiUser;
} }
/**
* Indicates whether the server is running in remote mode.
*
* In remote mode, the server is not assumed to be accessed only by a local user on the same machine,
* with corresponding limitations being enforced.
* Remote mode can be explicitly enabled by setting the environment variable PENPOT_MCP_REMOTE_MODE
* to "true". Enabling multi-user mode forces remote mode, regardless of the value of the environment
* variable.
*/
public isRemoteMode(): boolean {
const isRemoteModeRequested: boolean = process.env.PENPOT_MCP_REMOTE_MODE === "true";
return this.isMultiUserMode() || isRemoteModeRequested;
}
/** /**
* Indicates whether file system access is enabled for MCP tools. * Indicates whether file system access is enabled for MCP tools.
* Access is enabled only in single-user mode, where the file system is assumed * Access is enabled only in local mode, where the file system is assumed
* to belong to the user running the server locally. * to belong to the user running the server locally.
*/ */
public isFileSystemAccessEnabled(): boolean { public isFileSystemAccessEnabled(): boolean {
return !this.isMultiUserMode(); return !this.isRemoteMode();
} }
public getInitialInstructions(): string { public getInitialInstructions(): string {
@ -210,10 +230,11 @@ export class PenpotMcpServer {
return new Promise((resolve) => { return new Promise((resolve) => {
this.app.listen(this.port, async () => { this.app.listen(this.port, async () => {
this.logger.info(`Penpot MCP Server started on port ${this.port}`); this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`);
this.logger.info(`Modern Streamable HTTP endpoint: http://localhost:${this.port}/mcp`); this.logger.info(`Remote mode: ${this.isRemoteMode()}`);
this.logger.info(`Legacy SSE endpoint: http://localhost:${this.port}/sse`); this.logger.info(`Modern Streamable HTTP endpoint: http://${this.serverAddress}:${this.port}/mcp`);
this.logger.info(`WebSocket server is on ws://localhost:${this.webSocketPort}`); this.logger.info(`Legacy SSE endpoint: http://${this.serverAddress}:${this.port}/sse`);
this.logger.info(`WebSocket server URL: ws://${this.serverAddress}:${this.webSocketPort}`);
// start the REPL server // start the REPL server
await this.replServer.start(); await this.replServer.start();

View File

@ -23,7 +23,7 @@ export class PluginBridge {
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map(); private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map();
constructor( constructor(
private mcpServer: PenpotMcpServer, public readonly mcpServer: PenpotMcpServer,
private port: number, private port: number,
private taskTimeoutSecs: number = 30 private taskTimeoutSecs: number = 30
) { ) {

View File

@ -88,7 +88,9 @@ export class ReplServer {
return new Promise((resolve) => { return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => { this.server = this.app.listen(this.port, () => {
this.logger.info(`REPL server started on port ${this.port}`); this.logger.info(`REPL server started on port ${this.port}`);
this.logger.info(`REPL interface available at: http://localhost:${this.port}`); this.logger.info(
`REPL interface URL: http://${this.pluginBridge.mcpServer.serverAddress}:${this.port}`
);
resolve(); resolve();
}); });
}); });

View File

@ -9,9 +9,7 @@ import { createLogger, logFilePath } from "./logger";
* Creates and starts the MCP server instance, handling any startup errors * Creates and starts the MCP server instance, handling any startup errors
* gracefully and ensuring proper process termination. * gracefully and ensuring proper process termination.
* *
* Usage: * Configuration via environment variables (see README).
* - Help: node dist/index.js --help
* - Default configuration: runs on port 4401, logs to mcp-server/logs at info level
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
const logger = createLogger("main"); const logger = createLogger("main");
@ -21,43 +19,25 @@ async function main(): Promise<void> {
try { try {
const args = process.argv.slice(2); const args = process.argv.slice(2);
let port = 4401; // default port
let multiUser = false; // default to single-user mode let multiUser = false; // default to single-user mode
// parse command line arguments // parse command line arguments
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" || args[i] === "-p") { if (args[i] === "--multi-user") {
if (i + 1 < args.length) {
const portArg = parseInt(args[i + 1], 10);
if (!isNaN(portArg) && portArg > 0 && portArg <= 65535) {
port = portArg;
} else {
logger.info("Invalid port number. Using default port 4401.");
}
}
} else if (args[i] === "--log-level" || args[i] === "-l") {
if (i + 1 < args.length) {
process.env.LOG_LEVEL = args[i + 1];
}
} else if (args[i] === "--log-dir") {
if (i + 1 < args.length) {
process.env.LOG_DIR = args[i + 1];
}
} else if (args[i] === "--multi-user") {
multiUser = true; multiUser = true;
} else if (args[i] === "--help" || args[i] === "-h") { } else if (args[i] === "--help" || args[i] === "-h") {
logger.info("Usage: node dist/index.js [options]"); logger.info("Usage: node dist/index.js [options]");
logger.info("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(" --multi-user Enable multi-user mode (default: single-user)");
logger.info(" --help, -h Show this help message"); logger.info(" --help, -h Show this help message");
logger.info("");
logger.info("Note that configuration is mostly handled through environment variables.");
logger.info("Refer to the README for more information.");
process.exit(0); process.exit(0);
} }
} }
const server = new PenpotMcpServer(port, undefined, undefined, multiUser); const server = new PenpotMcpServer(multiUser);
await server.start(); await server.start();
// keep the process alive // keep the process alive

View File

@ -4,8 +4,8 @@ import { join, resolve } from "path";
/** /**
* Configuration for log file location and level. * Configuration for log file location and level.
*/ */
const LOG_DIR = process.env.LOG_DIR || "logs"; const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR || "logs";
const LOG_LEVEL = process.env.LOG_LEVEL || "info"; const LOG_LEVEL = process.env.PENPOT_MCP_LOG_LEVEL || "info";
/** /**
* Generates a timestamped log file name. * Generates a timestamped log file name.

View File

@ -51,7 +51,7 @@ function connectToMcpServer(): void {
} }
try { try {
let wsUrl = "ws://localhost:4402"; let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) { if (isMultiUserMode) {
// TODO obtain proper userToken from penpot // TODO obtain proper userToken from penpot
const userToken = "dummyToken"; const userToken = "dummyToken";

View File

@ -1 +1,4 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const IS_MULTI_USER_MODE: boolean;
declare const PENPOT_MCP_WEBSOCKET_URL: string;

View File

@ -1,10 +1,15 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import livePreview from "vite-live-preview"; import livePreview from "vite-live-preview";
// Debug: Log the environment variable // Debug: Log the environment variables
console.log("MULTI_USER_MODE env:", process.env.MULTI_USER_MODE); 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")); console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(process.env.MULTI_USER_MODE === "true"));
const serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS || "localhost";
const websocketPort = process.env.PENPOT_MCP_WEBSOCKET_PORT || "4402";
const websocketUrl = `ws://${serverAddress}:${websocketPort}`;
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(websocketUrl));
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
livePreview({ livePreview({
@ -30,8 +35,12 @@ export default defineConfig({
preview: { preview: {
port: 4400, port: 4400,
cors: true, cors: true,
allowedHosts: process.env.PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS
? process.env.PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS.split(",").map((h) => h.trim())
: [],
}, },
define: { define: {
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"), IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(websocketUrl),
}, },
}); });