diff --git a/mcp/README.md b/mcp/README.md index 9b4ac77038..1b8dc3ea29 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -265,6 +265,7 @@ The Penpot MCP server can be configured using environment variables. | `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_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | diff --git a/mcp/packages/server/src/PenpotMcpServer.test.ts b/mcp/packages/server/src/PenpotMcpServer.test.ts index 99765b5927..5c04e50400 100644 --- a/mcp/packages/server/src/PenpotMcpServer.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.test.ts @@ -16,6 +16,28 @@ test("isDevEnvEnabled returns true when PENPOT_MCP_DEVENV is 'true'", () => { assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "true" }), true); }); +// ── Pure function tests: isReplEnabled ────────────────────────── + +test("isReplEnabled returns false when neither env var is set", () => { + assert.equal(PenpotMcpServer.isReplEnabled({}), false); +}); + +test("isReplEnabled returns true when PENPOT_MCP_DEVENV is 'true' (fallback)", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_DEVENV: "true" }), true); +}); + +test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true'", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true); +}); + +test("isReplEnabled returns false when PENPOT_MCP_REPL_ENABLE is 'false' even if DEVENV is true", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "false", PENPOT_MCP_DEVENV: "true" }), false); +}); + +test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true' regardless of DEVENV", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true); +}); + // ── Integration tests: constructor gating ────────────────────── // // Each test uses unique ports to avoid conflicts when tests run @@ -57,6 +79,40 @@ test("constructor creates ReplServer when PENPOT_MCP_DEVENV is 'true'", async () } }); +test("constructor creates ReplServer when PENPOT_MCP_REPL_ENABLE is 'true' without DEVENV", async () => { + const prevDevEnv = process.env.PENPOT_MCP_DEVENV; + const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE; + const prevPorts = setUniqueEnv(); + delete process.env.PENPOT_MCP_DEVENV; + process.env.PENPOT_MCP_REPL_ENABLE = "true"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), true); + } finally { + await server?.stop(); + restoreEnv(prevDevEnv, prevPorts); + restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable); + } +}); + +test("constructor does not create ReplServer when PENPOT_MCP_REPL_ENABLE is 'false' even with DEVENV", async () => { + const prevDevEnv = process.env.PENPOT_MCP_DEVENV; + const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE; + const prevPorts = setUniqueEnv(); + process.env.PENPOT_MCP_DEVENV = "true"; + process.env.PENPOT_MCP_REPL_ENABLE = "false"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), false); + } finally { + await server?.stop(); + restoreEnv(prevDevEnv, prevPorts); + restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable); + } +}); + // ── Helpers ──────────────────────────────────────────────────── function setUniqueEnv() { diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 49137b48b7..09849c9316 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -66,6 +66,20 @@ export class PenpotMcpServer { return env.PENPOT_MCP_DEVENV === "true"; } + /** + * Determines whether the REPL server should be enabled. + * + * If ``PENPOT_MCP_REPL_ENABLE`` is set, its value controls the result + * (``"true"`` enables, any other value disables). When the variable is + * not set, the result falls back to {@link isDevEnvEnabled}. + */ + public static isReplEnabled(env: Record): boolean { + if (env.PENPOT_MCP_REPL_ENABLE !== undefined) { + return env.PENPOT_MCP_REPL_ENABLE === "true"; + } + return PenpotMcpServer.isDevEnvEnabled(env); + } + /** * Returns a short, non-reversible fingerprint of a user token, suitable for * correlating log lines without exposing the full credential. @@ -160,7 +174,7 @@ export class PenpotMcpServer { this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge); - if (PenpotMcpServer.isDevEnvEnabled(process.env)) { + if (PenpotMcpServer.isReplEnabled(process.env)) { this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); } else { this.replServer = null; @@ -211,7 +225,9 @@ export class PenpotMcpServer { /** * Indicates whether the REPL server was created. * - * The REPL server is created only in development environment mode. + * The REPL server is created when {@link isReplEnabled} returns true, + * which means either ``PENPOT_MCP_REPL_ENABLE=true`` or, when that + * variable is unset, ``PENPOT_MCP_DEVENV=true``. */ public hasReplServer(): boolean { return this.replServer !== null; @@ -449,7 +465,9 @@ export class PenpotMcpServer { if (this.replServer) { await this.replServer.start(); } else { - this.logger.info("REPL server disabled (set PENPOT_MCP_DEVENV=true to enable)"); + this.logger.info( + "REPL server disabled (set PENPOT_MCP_REPL_ENABLE=true or PENPOT_MCP_DEVENV=true to enable)" + ); } this.startSessionTimeoutChecker();