From 4f852e33bfad1fd89790236f1e47299937adfd88 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 09:59:33 +0200 Subject: [PATCH] :rewind: Backport mcp package changes from develop --- mcp/.serena/memories/project_overview.md | 73 ++-- mcp/.serena/project.yml | 124 +++--- mcp/README.md | 17 +- mcp/packages/plugin/src/TaskHandler.ts | 26 +- mcp/packages/plugin/src/main.ts | 39 +- mcp/packages/plugin/src/plugin.ts | 15 +- .../server/data/initial_instructions.md | 7 +- mcp/packages/server/package.json | 8 +- ...integration-test-export-image-semaphore.ts | 116 ++++++ mcp/packages/server/src/NreplClient.ts | 217 ++++++++++ mcp/packages/server/src/PenpotMcpServer.ts | 98 ++++- mcp/packages/server/src/PluginBridge.ts | 146 ++++++- mcp/packages/server/src/PluginTask.ts | 117 ++++-- mcp/packages/server/src/RedisBridge.ts | 182 +++++++++ mcp/packages/server/src/RemotePluginTask.ts | 56 +++ .../server/src/tools/CljCheckParentheses.ts | 242 ++++++++++++ .../src/tools/CljsCompilerOutputTool.ts | 52 +++ mcp/packages/server/src/tools/CljsReplTool.ts | 75 ++++ .../server/src/tools/ExportShapeTool.ts | 55 ++- .../server/src/tools/ImportPenpotFileTool.ts | 370 ++++++++++++++++++ .../server/src/tools/ReadTaigaIssueTool.ts | 163 ++++++++ .../server/src/types/nrepl-client.d.ts | 53 +++ mcp/packages/server/src/utils/Semaphore.ts | 69 ++++ mcp/pnpm-lock.yaml | 364 ++++++++++++++++- mcp/scripts/start-mcp-devenv | 6 + 25 files changed, 2497 insertions(+), 193 deletions(-) create mode 100644 mcp/packages/server/scripts/integration-test-export-image-semaphore.ts create mode 100644 mcp/packages/server/src/NreplClient.ts create mode 100644 mcp/packages/server/src/RedisBridge.ts create mode 100644 mcp/packages/server/src/RemotePluginTask.ts create mode 100644 mcp/packages/server/src/tools/CljCheckParentheses.ts create mode 100644 mcp/packages/server/src/tools/CljsCompilerOutputTool.ts create mode 100644 mcp/packages/server/src/tools/CljsReplTool.ts create mode 100644 mcp/packages/server/src/tools/ImportPenpotFileTool.ts create mode 100644 mcp/packages/server/src/tools/ReadTaigaIssueTool.ts create mode 100644 mcp/packages/server/src/types/nrepl-client.d.ts create mode 100644 mcp/packages/server/src/utils/Semaphore.ts create mode 100755 mcp/scripts/start-mcp-devenv diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md index 7e80a5e2d5..64460c0289 100644 --- a/mcp/.serena/memories/project_overview.md +++ b/mcp/.serena/memories/project_overview.md @@ -1,53 +1,69 @@ -# Penpot MCP Project Overview - Updated +# Penpot MCP -## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. +This subproject provides an MCP server for Penpot integration. The MCP server communicates with a Penpot plugin via WebSockets, allowing -the MCP server to send tasks to the plugin and receive results, +the MCP server to send tasks to the plugin and receive results, enabling advanced AI-driven features in Penpot. ## Tech Stack -- **Language**: TypeScript -- **Runtime**: Node.js -- **Framework**: MCP SDK (@modelcontextprotocol/sdk) -- **Build Tool**: TypeScript Compiler (tsc) + esbuild -- **Package Manager**: pnpm -- **WebSocket**: ws library for real-time communication -## Project Structure +- Language: TypeScript +- Runtime: Node.js +- Framework: MCP SDK (@modelcontextprotocol/sdk) +- Build Tool: TypeScript Compiler (tsc) + esbuild +- Package Manager: pnpm + +## General Principles + +IMPORTANT: Use an idiomatic, object-oriented style. +In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions +rather than mere functions (i.e. use the strategy pattern, for example). + +Comments: +When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase +clearly defines *what* it is. Any details then follow in subsequent sentences. + +When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless +the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is +required for sentences). + +## Project Structure (Excerpt) + ``` / (project root) ├── packages/common/ # Shared type definitions │ ├── src/ -│ │ ├── index.ts # Exports for shared types +│ │ ├── index.ts # exports for shared types │ │ └── types.ts # PluginTaskResult, request/response interfaces │ └── package.json # @penpot-mcp/common package -├── packages/server/ # Main MCP server implementation +├── packages/server/ # MCP server subproject │ ├── src/ -│ │ ├── index.ts # Main server entry point -│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation -│ │ ├── PluginTask.ts # Now supports result promises +│ │ ├── index.ts # entry point +│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.) +│ │ ├── Tool.ts # base class for tools +│ │ ├── PluginTask.ts # base class for plugin tasks │ │ ├── tasks/ # PluginTask implementations │ │ └── tools/ # Tool implementations -| ├── data/ # Contains resources, such as API info and prompts -│ └── package.json # Includes @penpot-mcp/common dependency -├── packages/plugin/ # Penpot plugin with response capability +| ├── data/ # contains resources, such as API info and prompts +│ └── package.json +├── packages/plugin/ # Penpot plugin subproject │ ├── src/ -│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding -│ │ └── plugin.ts # Now sends task responses back to server +│ │ ├── main.ts # handles communication +│ │ └── plugin.ts # plugin implementation │ └── package.json # Includes @penpot-mcp/common dependency └── prepare-api-docs # Python project for the generation of API docs ``` -## Key Tasks +## Key Development Tasks -### Adjusting the System Prompt +### Adjusting the Prompts -The system prompt file is located in `packages/server/data/initial_instructions.md`. +The system prompt file (aka Penpot High-Level Overview) is located in +`packages/server/data/initial_instructions.md`. ### Adding a new Tool -1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. +1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. 2. Register the tool in `PenpotMcpServer`. @@ -62,3 +78,10 @@ Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced t * In the success case, call `task.sendSuccess`. * In the failure case, just throw an exception, which will be handled centrally! 4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. + +## Dev Tooling + +From the project root directory, run + +* `pnpm run build` to test the build of all package +* `pnpm run fmt` to apply the auto-formatter diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index e5729836cd..7fac475f69 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -1,11 +1,10 @@ -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 + +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -13,65 +12,15 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false - - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: | - IMPORTANT: You use an idiomatic, object-oriented style. - In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions - rather than mere functions (i.e. use the strategy pattern, for example). - - Always read the "project_overview" memory. - - Comments: - When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase - clearly defines *what* it is. Any details then follow in subsequent sentences. - - When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless - the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is - required for sentences). + CRITICAL: Always read the "project_overview" memory. # the name by which the project can be referenced within Serena project_name: "penpot-mcp" @@ -83,18 +32,24 @@ project_name: "penpot-mcp" # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # the encoding used by text files in the project @@ -102,23 +57,28 @@ fixed_tools: [] encoding: utf-8 - # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -164,3 +124,19 @@ ignored_memory_patterns: [] # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/mcp/README.md b/mcp/README.md index c13c1803e2..3efc6255e7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -264,13 +264,16 @@ The Penpot MCP server can be configured using environment variables. ### Server Configuration -| Environment Variable | Description | Default | -|------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | -| `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_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | +| Environment Variable | Description | Default | +|--------------------------------------------------|----------------------------------------------------------------------------|----------------| +| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | +| `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_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_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | +| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | ### Logging Configuration diff --git a/mcp/packages/plugin/src/TaskHandler.ts b/mcp/packages/plugin/src/TaskHandler.ts index b09a6c0c17..a8f05a4229 100644 --- a/mcp/packages/plugin/src/TaskHandler.ts +++ b/mcp/packages/plugin/src/TaskHandler.ts @@ -24,8 +24,10 @@ export class Task { return; } + // create response object const response = { type: "task-response", + // NOTE: This inner response schema also constructed in main.ts/sendTaskResponse. response: { id: this.requestId, success: success, @@ -35,7 +37,29 @@ export class Task { }; // Send to main.ts which will forward to MCP server via WebSocket - penpot.ui.sendMessage(response); + try { + // TODO: Remove ts-ignore once Penpot types have been updated + // @ts-ignore + penpot.ui.sendMessage(response, true); + } catch (sendError) { + console.error("Failed to transfer task response for transmission to MCP server", sendError); + if (success) { + console.info("Original response was a success response, sending an error response instead."); + // If we were trying to send a success response, send an error response instead + let cause = sendError instanceof Error ? sendError.message : String(sendError); + this.sendError( + "Unexpected error sending task response: " + + cause + + " If you are an LLM and tried to serialize a regular object (shape, token, etc.), " + + "tell the user to report this error, providing information on the request you made." + ); + } else { + console.info( + "Original response was an error response, abandoning attempt to send alternative response to avoid infinite error loop." + ); + } + return; + } console.log("Sent task response:", response); this.isResponseSent = true; } diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 40b5bd7ba8..94210da288 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,12 +1,29 @@ import "./style.css"; +/** + * the maximum allowed size for task responses sent back to the MCP server in the integrated remote MCP mode. + * This bounds the JSON response size. + * Note that in the remote MCP case, responses are transferred to LLMs (not the file system) and LLMs have + * size limitations. This serves to bound the size of returned images in particular. + * Too many overly large simultaneous responses can cause OOM issues in the MCP server, so this contributes + * to bounding memory usage in the centrally provided MCP server. + */ +const MAX_TASK_RESPONSE_SIZE_REMOTE_MCP = 15_000_000; + // get the current theme from the URL const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// WebSocket connection management +// WebSocket connection to the MCP server let ws: WebSocket | null = null; +/** + * indicates whether the plugin is running with the Penpot-integrated remote MCP server enabled + * (as opposed to a local server used with the explicitly loaded plugin); + * set via the "mcp-mode" message sent by plugin.ts on initialization + */ +let isIntegratedRemoteMcp = false; + const statusPill = document.getElementById("connection-status") as HTMLElement; const statusText = document.getElementById("status-text") as HTMLElement; const currentTaskEl = document.getElementById("current-task") as HTMLElement; @@ -79,7 +96,22 @@ function updateExecutedCode(code: string | null): void { */ function sendTaskResponse(response: any): void { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(response)); + let responseString = JSON.stringify(response); + if (isIntegratedRemoteMcp && responseString.length > MAX_TASK_RESPONSE_SIZE_REMOTE_MCP) { + const errorMessage = `Serialised response size (${responseString.length}) exceeds maximum of ${MAX_TASK_RESPONSE_SIZE_REMOTE_MCP}.`; + console.warn( + errorMessage + + " [integrated remote MCP mode restriction]; sending error response instead; original response:", + response + ); + response = { + id: response.id, + success: false, + error: errorMessage, + }; + responseString = JSON.stringify(response); + } + ws.send(responseString); console.log("Sent response to MCP server:", response); } else { console.error("WebSocket not connected, cannot send response"); @@ -176,6 +208,9 @@ disconnectBtn?.addEventListener("click", () => { // Listen plugin.ts messages window.addEventListener("message", (event) => { + if (event.data.type === "mcp-mode") { + isIntegratedRemoteMcp = event.data.integratedRemoteMcp; + } if (event.data.type === "start-server") { connectToMcpServer(event.data.url, event.data.token); } diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index 3827db70eb..34e0349e96 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,6 +1,12 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server + * enabled (as opposed to a local server used with the explicitly loaded plugin) + */ +const isIntegratedRemoteMcp = !!mcp; + /** * Extracts the major.minor.patch prefix from a version string. * @@ -23,12 +29,17 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { width: 236, height: 210, - hidden: !!mcp, + hidden: isIntegratedRemoteMcp, } as any); // Register message handlers penpot.ui.onMessage((message) => { if (typeof message === "object" && message.type === "ui-initialized") { + // Inform the UI about the operating mode + penpot.ui.sendMessage({ + type: "mcp-mode", + integratedRemoteMcp: isIntegratedRemoteMcp, + }); // Check Penpot version compatibility const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); @@ -42,7 +53,7 @@ penpot.ui.onMessage N parallel MCP clients that each call the export_shape tool. + * + * Expectations (observed manually from the server's console output): + * - "Semaphore 'ExportShapeTool' saturated; request queued (k waiting)" lines appear + * at INFO level (at least M - N of them). + * - All M tool calls return successfully. + * - Total elapsed wall-clock time is approximately ceil(M / N) * SLEEP_MS. + * + * Invoke from packages/server with: + * pnpm run test:integration:export + */ +import * as net from "node:net"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { PenpotMcpServer } from "../src/PenpotMcpServer"; +import { ExportShapeTool } from "../src/tools/ExportShapeTool"; +import { TextResponse } from "../src/ToolResponse"; +import { Semaphore } from "../src/utils/Semaphore"; + +// === parameters === + +const N = 3; +const M = 6; +const SLEEP_MS = 5_000; + +// === helpers === + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); +} + +async function callExportShape(url: URL, idx: number): Promise { + const client = new Client({ name: `integration-test-client-${idx}`, version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport(url); + await client.connect(transport); + try { + return await client.callTool({ + name: "export_shape", + arguments: { shapeId: "selection" }, + }); + } finally { + await client.close(); + } +} + +// === main === + +async function main(): Promise { + // dynamic ports must be set before PenpotMcpServer is constructed + const httpPort = await findFreePort(); + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(httpPort); + process.env.PENPOT_MCP_WEBSOCKET_PORT = String(await findFreePort()); + process.env.PENPOT_MCP_REPL_PORT = String(await findFreePort()); + + // shrink the gate and stub the worker + (ExportShapeTool as any).parallelismSemaphore = new Semaphore("ExportShapeTool", N); + (ExportShapeTool.prototype as any).exportImage = async (): Promise => { + await new Promise((r) => setTimeout(r, SLEEP_MS)); + return new TextResponse("stubbed export"); + }; + + const server = new PenpotMcpServer(/* isMultiUser */ true); + await server.start(); + + console.log(`\n=== integration test: N=${N} permits, M=${M} clients, sleep=${SLEEP_MS}ms ===\n`); + + // fire M parallel tool calls, each via its own MCP session with a distinct userToken + const start = Date.now(); + const results = await Promise.allSettled( + Array.from({ length: M }, (_, i) => + callExportShape(new URL(`http://127.0.0.1:${httpPort}/mcp?userToken=test-token-${i}`), i) + ) + ); + const elapsed = Date.now() - start; + + await server.stop(); + + // report + const failures = results.map((r, i) => ({ r, i })).filter(({ r }) => r.status === "rejected"); + + console.log(`\n=== results ===`); + console.log(` elapsed: ${elapsed}ms (expected ~${Math.ceil(M / N) * SLEEP_MS}ms)`); + console.log(` succeeded: ${M - failures.length}/${M}`); + if (failures.length > 0) { + console.log(` failures:`); + for (const { r, i } of failures) { + console.log(` [${i}] ${(r as PromiseRejectedResult).reason}`); + } + process.exit(1); + } + console.log(`\nAll ${M} tool calls succeeded. Scroll up to verify saturation log lines.\n`); + process.exit(0); +} + +main().catch((err) => { + console.error("Integration test crashed:", err); + process.exit(1); +}); diff --git a/mcp/packages/server/src/NreplClient.ts b/mcp/packages/server/src/NreplClient.ts new file mode 100644 index 0000000000..d550812594 --- /dev/null +++ b/mcp/packages/server/src/NreplClient.ts @@ -0,0 +1,217 @@ +import nreplClient from "nrepl-client"; +import type { NreplConnection, NreplMessage } from "nrepl-client"; +import { createLogger } from "./logger"; + +/** + * Result of evaluating a ClojureScript expression via nREPL. + */ +export interface NreplEvalResult { + /** the returned value(s) as strings */ + values: string[]; + /** captured stdout output */ + out: string; + /** captured stderr output */ + err: string; + /** the namespace after evaluation */ + ns: string; +} + +/** + * A client for communicating with a shadow-cljs nREPL server. + * + * This client maintains a persistent nREPL session, so that definitions, + * requires, and other state are preserved across evaluations — providing + * a full REPL experience. + */ +export class NreplClient { + private static readonly NREPL_PORT = 3447; + private static readonly NREPL_HOST = "localhost"; + private static readonly EVAL_TIMEOUT_MS = 30_000; + + private readonly logger = createLogger("NreplClient"); + + /** the persistent connection to the nREPL server, established lazily */ + private connection: NreplConnection | null = null; + + /** the cloned session ID that persists state across evaluations */ + private sessionId: string | null = null; + + /** + * Evaluates a Clojure expression on the nREPL server within the persistent session. + * + * @param code - the Clojure expression to evaluate + * @returns the evaluation result + */ + async eval(code: string): Promise { + this.logger.debug("Evaluating Clojure expression: %s", code); + const conn = await this.ensureConnection(); + const sessionId = await this.ensureSession(conn); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`)); + }, NreplClient.EVAL_TIMEOUT_MS); + + conn.send({ op: "eval", code, session: sessionId }, (err: Error | null, result: NreplMessage[]) => { + clearTimeout(timeout); + if (err) { + reject(err); + return; + } + try { + resolve(this.parseEvalResult(result)); + } catch (parseErr) { + reject(parseErr); + } + }); + }); + } + + /** + * Evaluates a ClojureScript expression via the shadow-cljs CLJS eval API. + * + * The expression is wrapped in a call to `shadow.cljs.devtools.api/cljs-eval` + * targeting the `:main` build, so it is evaluated in the browser runtime. + * + * @param cljsCode - the ClojureScript expression to evaluate + * @returns the evaluation result + */ + async evalCljs(cljsCode: string): Promise { + // escape the CLJS code for embedding in a Clojure string + const escapedCode = cljsCode.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const wrappedCode = `(shadow.cljs.devtools.api/cljs-eval :main "${escapedCode}" {})`; + this.logger.debug("Evaluating CLJS expression via shadow-cljs: %s", cljsCode); + return this.eval(wrappedCode); + } + + /** + * Closes the persistent connection and session, releasing all resources. + */ + async close(): Promise { + if (this.connection) { + this.logger.info("Closing nREPL connection"); + this.connection.end(); + this.connection = null; + this.sessionId = null; + } + } + + /** + * Ensures a connection to the nREPL server is established, creating one if necessary. + * + * If the existing connection has been closed or errored, a new one is created. + */ + private async ensureConnection(): Promise { + if (this.connection && !this.connection.destroyed) { + return this.connection; + } + + // reset state since the old connection is gone + this.connection = null; + this.sessionId = null; + + this.logger.info("Connecting to nREPL server at %s:%d", NreplClient.NREPL_HOST, NreplClient.NREPL_PORT); + + return new Promise((resolve, reject) => { + const conn = nreplClient.connect({ + port: NreplClient.NREPL_PORT, + host: NreplClient.NREPL_HOST, + }); + + conn.once("connect", () => { + this.connection = conn; + + // handle unexpected disconnects so the next eval reconnects + conn.once("close", () => { + this.logger.warn("nREPL connection closed unexpectedly"); + this.connection = null; + this.sessionId = null; + }); + + conn.once("error", (err: Error) => { + this.logger.error("nREPL connection error: %s", err); + this.connection = null; + this.sessionId = null; + }); + + resolve(conn); + }); + + conn.once("error", (err: Error) => { + reject( + new Error( + `Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}` + ) + ); + }); + }); + } + + /** + * Ensures a persistent nREPL session exists, cloning one from the server if necessary. + * + * A cloned session maintains its own state (namespace bindings, definitions, etc.) + * independently of other sessions. + */ + private async ensureSession(conn: NreplConnection): Promise { + if (this.sessionId) { + return this.sessionId; + } + + this.logger.info("Cloning new nREPL session"); + + return new Promise((resolve, reject) => { + conn.clone((err: Error | null, result: NreplMessage[]) => { + if (err) { + reject(new Error(`Failed to clone nREPL session: ${err.message}`)); + return; + } + + const sessionMsg = result.find((msg) => msg["new-session"] !== undefined) as any; + if (!sessionMsg) { + reject(new Error("nREPL clone response did not contain a new session ID")); + return; + } + + this.sessionId = sessionMsg["new-session"]; + this.logger.info("Cloned nREPL session: %s", this.sessionId); + resolve(this.sessionId!); + }); + }); + } + + /** + * Parses the raw nREPL response messages into a structured result. + */ + private parseEvalResult(messages: NreplMessage[]): NreplEvalResult { + const values: string[] = []; + const outParts: string[] = []; + const errParts: string[] = []; + let ns = "user"; + + for (const msg of messages) { + if (msg.value !== undefined) { + values.push(msg.value); + } + if (msg.out) { + outParts.push(msg.out); + } + if (msg.err) { + errParts.push(msg.err); + } + if (msg.ns) { + ns = msg.ns; + } + if (msg.ex) { + throw new Error(`nREPL evaluation error: ${msg.ex}${msg.err ? "\n" + msg.err : ""}`); + } + } + + return { + values, + out: outParts.join(""), + err: errParts.join(""), + ns, + }; + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 6bd3ca33e5..2695acdd65 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -4,6 +4,7 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { ExecuteCodeTool } from "./tools/ExecuteCodeTool"; import { PluginBridge } from "./PluginBridge"; +import { RedisBridge } from "./RedisBridge"; import { ConfigurationLoader } from "./ConfigurationLoader"; import { createLogger } from "./logger"; import { Tool } from "./Tool"; @@ -11,6 +12,12 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; +import { CljsReplTool } from "./tools/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; +import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -45,7 +52,7 @@ class ToolInfo { export class PenpotMcpServer { /** - * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + * Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed. */ private static readonly SESSION_TIMEOUT_MINUTES = 60; @@ -87,7 +94,10 @@ export class PenpotMcpServer { private readonly sessionContext = new AsyncLocalStorage(); private readonly streamableTransports: Record = {}; - private readonly sseTransports: Record = {}; + private readonly sseTransports: Record< + string, + { transport: SSEServerTransport; userToken?: string; lastActiveTime: number } + > = {}; public readonly host: string; public readonly port: number; @@ -95,6 +105,12 @@ export class PenpotMcpServer { public readonly replPort: number; private sessionTimeoutInterval: ReturnType | undefined; + /** + * Optional Redis bridge for multi-instance task routing; present only when running + * in multi-user mode with a configured Redis URI. + */ + private readonly redisBridge?: RedisBridge; + constructor(private isMultiUser: boolean = false) { // read port configuration from environment variables this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; @@ -113,7 +129,15 @@ export class PenpotMcpServer { this.tools = this.initTools(); - this.pluginBridge = new PluginBridge(this, this.webSocketPort); + // Enable multi-instance task routing when running in multi-user mode with a + // configured Redis URI. Without it, the server operates in single-instance mode, + // requiring the plugin and the MCP client to connect to the same instance. + const redisUri = process.env.PENPOT_MCP_REDIS_URI; + if (this.isMultiUser && redisUri) { + this.redisBridge = new RedisBridge(redisUri); + } + + this.pluginBridge = new PluginBridge(this, this.webSocketPort, this.redisBridge); this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); } @@ -148,6 +172,16 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } + /** + * Indicates whether the server is running in a Penpot development environment. + * + * When enabled (by setting the environment variable PENPOT_MCP_DEVENV to "true"), + * additional developer tools such as ClojureScript expression evaluation are exposed. + */ + public isDevEnv(): boolean { + return process.env.PENPOT_MCP_DEVENV === "true"; + } + /** * Retrieves the high-level overview instructions explaining core Penpot usage. */ @@ -174,6 +208,14 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + if (this.isDevEnv()) { + const nreplClient = new NreplClient(); + toolInstances.push(new CljsReplTool(this, nreplClient)); + toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); + toolInstances.push(new CljsCompilerOutputTool(this, nreplClient)); + toolInstances.push(new CljCheckParentheses(this)); + toolInstances.push(new ReadTaigaIssueTool(this)); + } return toolInstances.map((instance) => { this.logger.info(`Registering tool: ${instance.getToolName()}`); @@ -201,7 +243,7 @@ export class PenpotMcpServer { } /** - * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * Starts a periodic timer that closes and removes Streamable HTTP and SSE sessions that have been * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. */ private startSessionTimeoutChecker(): void { @@ -217,8 +259,18 @@ export class PenpotMcpServer { removed++; } } + for (const [id, session] of Object.entries(this.sseTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + this.logger.info(`Closing stale SSE session ${id}`); + session.transport.close(); + delete this.sseTransports[id]; + removed++; + } + } this.logger.info( - `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` + `Removed ${removed} stale session(s); total sessions remaining: ${ + Object.keys(this.streamableTransports).length + Object.keys(this.sseTransports).length + }` ); }, checkIntervalMs); } @@ -247,15 +299,21 @@ export class PenpotMcpServer { `Received request for existing session with id=${sessionId}; userTokenFp=${PenpotMcpServer.tokenFingerprint(session.userToken)}` ); } else { - // new session: create a fresh McpServer and transport + // No locally-known session for this request. Either a brand-new session + // (no session ID) or a session that was initialized on another instance + // and routed here by the load balancer (session ID present but unknown + // locally), which we adopt rather than reject. + const isAdoptedSession = sessionId !== undefined; userToken = req.query.userToken as string | undefined; this.logger.info( - `Received new session request; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + `${isAdoptedSession ? `Adopting session initialized on another instance with id=${sessionId}` : "Received new session request"}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` ); + const { randomUUID } = await import("node:crypto"); const server = this.createMcpServer(); transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + // For an adopted session, reuse the existing ID; otherwise generate a new one. + sessionIdGenerator: () => (isAdoptedSession ? sessionId! : randomUUID()), onsessioninitialized: (id) => { this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); this.logger.info( @@ -263,6 +321,22 @@ export class PenpotMcpServer { ); }, }); + + if (isAdoptedSession) { + // Pre-initialize the transport so that the SDK's validateSession() accepts + // subsequent (non-initialize) requests for this session ID. The SDK stores + // these on the inner WebStandardStreamableHTTPServerTransport as plain + // (non-#private) properties; validateSession() checks exactly _initialized + // and sessionId. Verified against @modelcontextprotocol/sdk 1.25.3. + // + // Since no initialize request will arrive for an adopted session, the + // onsessioninitialized callback will not fire; register the session here. + const inner = (transport as any)._webStandardTransport; + inner._initialized = true; + inner.sessionId = sessionId; + this.streamableTransports[sessionId!] = new StreamableSession(transport, userToken, Date.now()); + } + transport.onclose = () => { if (transport.sessionId) { this.logger.info( @@ -288,7 +362,7 @@ export class PenpotMcpServer { await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.sseTransports[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken, lastActiveTime: Date.now() }; const server = this.createMcpServer(); await server.connect(transport); @@ -307,6 +381,7 @@ export class PenpotMcpServer { const session = this.sseTransports[sessionId]; if (session) { + session.lastActiveTime = Date.now(); await this.sessionContext.run({ userToken: session.userToken }, async () => { await session.transport.handlePostMessage(req, res, req.body); }); @@ -326,7 +401,11 @@ export class PenpotMcpServer { return new Promise((resolve) => { this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); + this.logger.info( + `Multi-instance mode with Redis-backed transport: ${this.redisBridge ? "true" : "false"}` + ); this.logger.info(`Remote mode: ${this.isRemoteMode()}`); + this.logger.info(`DevEnv mode: ${this.isDevEnv()}`); this.logger.info(`Modern Streamable HTTP endpoint: http://${this.host}:${this.port}/mcp`); this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); @@ -348,6 +427,7 @@ export class PenpotMcpServer { public async stop(): Promise { this.logger.info("Stopping Penpot MCP Server..."); clearInterval(this.sessionTimeoutInterval); + await this.redisBridge?.close(); await this.replServer.stop(); this.logger.info("Penpot MCP Server stopped"); } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 35d39aa728..1c24547b8f 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -1,9 +1,11 @@ import { WebSocket, WebSocketServer } from "ws"; import * as http from "http"; -import { PluginTask } from "./PluginTask"; -import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; +import { AbstractPluginTask, PluginTask } from "./PluginTask"; +import { RemotePluginTask } from "./RemotePluginTask"; +import { PluginTaskRequest, PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +import type { RedisBridge } from "./RedisBridge"; const KEEP_ALIVE_TIME = 30000; // 30 seconds @@ -22,12 +24,24 @@ export class PluginBridge { private readonly wsServer: WebSocketServer; private readonly connectedClients: Map = new Map(); private readonly clientsByToken: Map = new Map(); - private readonly pendingTasks: Map> = new Map(); + private readonly pendingTasks: Map> = new Map(); private readonly taskTimeouts: Map = new Map(); + /** + * Creates the plugin bridge and starts its WebSocket server. + * + * @param mcpServer - The owning MCP server + * @param port - The port on which to listen for plugin WebSocket connections + * @param redisBridge - Optional Redis bridge enabling multi-instance task routing. + * When provided, tasks handled by this instance are routed to the instance + * holding the relevant plugin's WebSocket connection (which may be this same + * instance) via Redis, rather than dispatched directly over a local socket. + * @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution + */ constructor( public readonly mcpServer: PenpotMcpServer, private port: number, + private readonly redisBridge?: RedisBridge, private taskTimeoutSecs: number = 30 ) { this.wsServer = new WebSocketServer({ port: port }); @@ -77,6 +91,17 @@ export class PluginBridge { } this.clientsByToken.set(userToken, connection); + + // In multi-instance mode, subscribe to this token's Redis request channel so + // that task requests issued by other instances are dispatched to this plugin. + if (this.redisBridge) { + const tokenForSubscription = userToken; + this.redisBridge + .subscribeToTasks(userToken, (request) => + this.dispatchForwardedTask(tokenForSubscription, request) + ) + .catch((error) => this.logger.error(error, "Failed to subscribe to Redis task channel")); + } } ws.on("message", (data: Buffer) => { @@ -121,6 +146,12 @@ export class PluginBridge { this.connectedClients.delete(ws); if (connection.userToken) { this.clientsByToken.delete(connection.userToken); + + if (this.redisBridge) { + this.redisBridge + .unsubscribeFromTasks(connection.userToken) + .catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel")); + } } } @@ -203,10 +234,9 @@ export class PluginBridge { } /** - * Executes a plugin task by sending it to connected clients. - * - * Registers the task for result correlation and returns a promise - * that resolves when the plugin responds with the execution result. + * Executes a plugin task by sending it to the connected Penpot plugin instance, + * either directly via WebSocket or indirectly via Redis (depending on the configuration), + * and awaiting the result. * * @param task - The plugin task to execute * @throws Error if no plugin instances are connected or available @@ -214,28 +244,67 @@ export class PluginBridge { public async executePluginTask>( task: PluginTask ): Promise { - // get the appropriate client connection based on mode - const connection = this.getClientConnection(); + this.sendPluginTask(task, this.redisBridge !== undefined); + return await task.getResultPromise(); + } - // register the task for result correlation - this.pendingTasks.set(task.id, task); + /** + * Registers a task for response correlation, sends its request over the appropriate + * transport, and arms a timeout that rejects the task if no response is received. + * + * The response (whether arriving over the local WebSocket or over Redis) is later + * matched by ID in {@link handlePluginTaskResponse}, which settles the task via its + * `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout + * handling therefore applies regardless of the transport. + * + * @param task - The task to dispatch + * @param useRedis - Whether to route the request via Redis (multi-instance) rather + * than directly over the local WebSocket connection + * @param connection - The connection to use for a local (non-remote) dispatch; when + * omitted, the session's connection is resolved via {@link getClientConnection}. + * Ignored when `useRedis` is true. + * @throws Error if a local dispatch is required but no suitable connection is available + */ + private sendPluginTask(task: AbstractPluginTask, useRedis: boolean, connection?: ClientConnection): void { + let onTimeout: (() => void) | undefined; - // send task to the selected client - const requestMessage = JSON.stringify(task.toRequest()); - if (connection.socket.readyState !== 1) { - // WebSocket is not open - this.pendingTasks.delete(task.id); - throw new Error(`Plugin instance is disconnected. Task could not be sent.`); + if (useRedis) { + const sessionContext = this.mcpServer.getSessionContext(); + if (!sessionContext?.userToken) { + throw new Error("No userToken found in session context. Multi-user mode requires authentication."); + } + const userToken = sessionContext.userToken; + const redisBridge = this.redisBridge!; + this.logger.debug("Dispatching task %s via Redis", task.id); + + // register the task for result correlation, then publish the request via Redis + this.pendingTasks.set(task.id, task); + void redisBridge.sendTaskRequest(userToken, task.toRequest(), (response) => + this.handlePluginTaskResponse(response) + ); + + // on timeout, release the response-channel subscription, since no response + // will arrive to trigger its self-unsubscribe. + onTimeout = () => void redisBridge.unsubscribeFromResponse(task.id); + } else { + const target = connection ?? this.getClientConnection(); + if (target.socket.readyState !== 1) { + // WebSocket is not open + throw new Error(`Plugin instance is disconnected. Task could not be sent.`); + } + + // register the task for result correlation, then send over the socket + this.pendingTasks.set(task.id, task); + target.socket.send(JSON.stringify(task.toRequest())); } - 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); if (pendingTask) { this.pendingTasks.delete(task.id); this.taskTimeouts.delete(task.id); + onTimeout?.(); pendingTask.rejectWithError( new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`) ); @@ -243,8 +312,43 @@ export class PluginBridge { }, this.taskTimeoutSecs * 1000); this.taskTimeouts.set(task.id, timeoutHandle); - this.logger.info(`Sent task ${task.id} to connected client`); + this.logger.info(`Sent task ${task.id}`); + } - return await task.getResultPromise(); + /** + * Dispatches a task request received over Redis to the locally-connected plugin. + * + * Invoked on the instance subscribed to a user token's request channel when another + * instance (or this one) issues a task request. A {@link RemotePluginTask} is created + * so that, once the plugin responds, the outcome is published back to the issuing + * instance's Redis response channel via the standard response-handling path. + * + * On failure to dispatch (e.g. the plugin is not connected here), an error response + * is published immediately so the requester need not wait for its timeout. + * + * @param userToken - The user token on whose request channel the request arrived; + * identifies the locally-connected plugin to dispatch to + * @param request - The serialized task request, passed through from Redis + */ + private dispatchForwardedTask(userToken: string, request: PluginTaskRequest): void { + if (!this.redisBridge) { + return; + } + + // The response is published on the channel keyed by the original request ID. + const task = new RemotePluginTask(request.task, request.params, this.redisBridge, request.id); + this.logger.debug("Dispatching remote task %s as %s to Penpot via WebSocket", request.id, task.id); + + const connection = this.clientsByToken.get(userToken); + if (!connection) { + task.rejectWithError(new Error("Plugin not connected on the receiving instance")); + return; + } + + try { + this.sendPluginTask(task, false, connection); + } catch (error) { + task.rejectWithError(error instanceof Error ? error : new Error(String(error))); + } } } diff --git a/mcp/packages/server/src/PluginTask.ts b/mcp/packages/server/src/PluginTask.ts index 8600cac22b..0e0d327564 100644 --- a/mcp/packages/server/src/PluginTask.ts +++ b/mcp/packages/server/src/PluginTask.ts @@ -1,24 +1,28 @@ /** - * Base class for plugin tasks that are sent over WebSocket. + * Base classes for plugin tasks that are dispatched to a Penpot plugin instance + * over a WebSocket connection. * - * Each task defines a specific operation for the plugin to execute - * along with strongly-typed parameters. - * - * @template TParams - The strongly-typed parameters for this task + * A task defines a specific operation for the plugin to execute along with + * strongly-typed parameters and provides request/response correlation. */ import { PluginTaskRequest, PluginTaskResult } from "@penpot/mcp-common"; import { randomUUID } from "crypto"; /** - * Base class for plugin tasks that are sent over WebSocket. + * Abstract base for plugin tasks, defining the parts that the plugin dispatch and + * response-correlation machinery (`PluginBridge.sendPluginTask` / + * `PluginBridge.handlePluginTaskResponse`) depend upon. * - * Each task defines a specific operation for the plugin to execute - * along with strongly-typed parameters and request/response correlation. + * The dispatch path only needs to serialize a task to a request and, upon receiving + * the plugin's response, settle the task via `resolveWithResult`/`rejectWithError`. + * What "settling" actually means is left to subclasses: a local task resolves an + * in-process promise (see {@link PluginTask}), whereas a remote task forwards the + * outcome elsewhere (see {@link RemotePluginTask}). * * @template TParams - The strongly-typed parameters for this task * @template TResult - The expected result type from task execution */ -export abstract class PluginTask = PluginTaskResult> { +export abstract class AbstractPluginTask = PluginTaskResult> { /** * Unique identifier for request/response correlation. */ @@ -34,6 +38,66 @@ export abstract class PluginTask = PluginTaskResult, +> extends AbstractPluginTask { /** * Promise that resolves when the task execution completes. */ @@ -50,15 +114,13 @@ export abstract class PluginTask void; /** - * Creates a new plugin task instance. + * Creates a new locally-awaited plugin task. * * @param task - The name of the task to execute * @param params - The parameters for task execution */ constructor(task: string, params: TParams) { - this.id = randomUUID(); - this.task = task; - this.params = params; + super(task, params); this.result = new Promise((resolve, reject) => { this.resolveResult = resolve; this.rejectResult = reject; @@ -77,14 +139,6 @@ export abstract class PluginTask void; + +/** + * Handler invoked for a task response arriving on a subscribed response channel. + */ +export type TaskResponseHandler = (response: PluginTaskResponse) => void; + +/** + * Provides a Redis-backed transport for routing plugin task requests and responses + * between MCP server instances. + * + * The bridge is a pure, stateless transport: it moves already-serialized + * `PluginTaskRequest` and `PluginTaskResponse` objects between instances and does not + * interpret their contents, correlate requests with responses, or impose timeouts. + * Correlation and timeout handling remain the responsibility of the caller (see + * `PluginBridge`, which routes Redis-delivered responses through the same + * pending-task machinery used for direct WebSocket dispatch). + * + * It enables a tool call handled on one instance to be executed against a plugin + * whose WebSocket connection lives on another instance: the request is published on a + * channel keyed by user token (to which the instance holding the plugin connection is + * subscribed), and the response is published on a channel keyed by task ID (to which + * the issuing instance subscribes). + * + * Two Redis connections are used, as ioredis requires a dedicated connection while + * subscribed: one for commands and publishing, and one for subscriptions. + */ +export class RedisBridge { + private readonly logger = createLogger("RedisBridge"); + private readonly publisher: Redis; + private readonly subscriber: Redis; + + /** + * Message handlers keyed by channel name. + * + * ioredis exposes a single, global message event for all subscribed channels, so + * incoming messages are dispatched to the correct handler by channel name. Both + * request-channel and response-channel handlers are stored here. + */ + private readonly handlers = new Map void>(); + + /** + * Creates a Redis bridge connected to the given Redis instance. + * + * @param redisUri - The Redis connection URI (e.g. `redis://host:6379`) + */ + constructor(redisUri: string) { + this.publisher = new Redis(redisUri); + this.subscriber = new Redis(redisUri); + + this.subscriber.on("message", (channel: string, rawMessage: string) => { + const handler = this.handlers.get(channel); + if (handler) { + handler(rawMessage); + } else { + this.logger.warn(`Received message on channel with no registered handler: ${channel}`); + } + }); + } + + /** + * Subscribes to the response channel for the given task ID and publishes the task + * request to the given user token's request channel. + * + * The response subscription is established *before* the request is published, to + * avoid a race in which the response would be published before the subscription is + * in place. The response handler is invoked at most once and the subscription is + * removed automatically upon delivery (response channels are single-use). + * + * @param userToken - The user token identifying the target plugin's request channel + * @param request - The serialized plugin task request, passed through verbatim + * @param onResponse - Handler invoked with the response when it arrives + */ + async sendTaskRequest( + userToken: string, + request: PluginTaskRequest, + onResponse: TaskResponseHandler + ): Promise { + const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${request.id}`; + const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + + this.handlers.set(responseChannel, (rawMessage) => { + // a response channel is single-use: remove the handler and unsubscribe on delivery + this.handlers.delete(responseChannel); + void this.subscriber.unsubscribe(responseChannel); + try { + onResponse(JSON.parse(rawMessage) as PluginTaskResponse); + } catch (error) { + this.logger.error(error, "Failed to parse task response message"); + } + }); + + await this.subscriber.subscribe(responseChannel); + // publish only once the response subscription is confirmed + await this.publisher.publish(requestChannel, JSON.stringify(request)); + } + + /** + * Unsubscribes from the response channel for the given task ID. + * + * Used to release a response subscription when no response will be processed (e.g. + * the awaiting task has timed out), since in that case the self-unsubscribe on + * delivery never occurs. + * + * @param taskId - The task ID whose response channel to unsubscribe from + */ + async unsubscribeFromResponse(taskId: string): Promise { + const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${taskId}`; + this.handlers.delete(responseChannel); + await this.subscriber.unsubscribe(responseChannel); + } + + /** + * Publishes a task response on the response channel for the given task ID. + * + * Used by the instance executing a forwarded task to return its outcome to the + * issuing instance. + * + * @param taskId - The ID of the originally requested task + * @param response - The serialized plugin task response, passed through verbatim + */ + publishTaskResponse(taskId: string, response: PluginTaskResponse): void { + const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${taskId}`; + void this.publisher.publish(responseChannel, JSON.stringify(response)); + } + + /** + * Subscribes to task requests for the given user token. + * + * The handler is invoked for each request arriving on the token's request channel. + * + * @param userToken - The user token whose request channel to subscribe to + * @param handler - The handler to invoke for incoming requests + */ + async subscribeToTasks(userToken: string, handler: TaskRequestHandler): Promise { + const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + this.handlers.set(requestChannel, (rawMessage) => { + try { + handler(JSON.parse(rawMessage) as PluginTaskRequest); + } catch (error) { + this.logger.error(error, "Failed to parse task request message"); + } + }); + await this.subscriber.subscribe(requestChannel); + } + + /** + * Unsubscribes from task requests for the given user token. + * + * @param userToken - The user token whose request channel to unsubscribe from + */ + async unsubscribeFromTasks(userToken: string): Promise { + const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + this.handlers.delete(requestChannel); + await this.subscriber.unsubscribe(requestChannel); + } + + /** + * Closes both Redis connections. Call on server shutdown. + */ + async close(): Promise { + await this.subscriber.quit(); + await this.publisher.quit(); + } +} diff --git a/mcp/packages/server/src/RemotePluginTask.ts b/mcp/packages/server/src/RemotePluginTask.ts new file mode 100644 index 0000000000..368623a5a0 --- /dev/null +++ b/mcp/packages/server/src/RemotePluginTask.ts @@ -0,0 +1,56 @@ +import { AbstractPluginTask } from "./PluginTask"; +import { PluginTaskResult } from "@penpot/mcp-common"; +import type { RedisBridge } from "./RedisBridge"; + +/** + * A plugin task whose outcome is forwarded back to a remote requester via Redis, + * rather than awaited in-process. + * + * This task type is used on the server instance that holds the plugin's WebSocket + * connection when a task request arrives over Redis (published by another instance + * that received the corresponding tool call). It is dispatched to the plugin through + * the ordinary local dispatch path; when the plugin responds, the response-correlation + * machinery settles this task, and the overridden `resolveWithResult`/`rejectWithError` + * publish the outcome back onto the requester's Redis response channel. + * + * Note that this task has its own ID (used to correlate the local WebSocket dispatch), + * distinct from the original requester's task ID, which keys the Redis response channel. + * + * It deliberately carries no result promise: settling the task *is* the side effect + * of publishing to Redis, and nothing awaits it locally. + */ +export class RemotePluginTask extends AbstractPluginTask> { + /** + * Creates a task that forwards its outcome to a Redis response channel. + * + * @param task - The name of the task to execute (from the incoming request) + * @param params - The parameters for task execution (from the incoming request) + * @param redisBridge - The Redis bridge used to publish the outcome + * @param originalTaskId - The ID of the original request, which keys the response + * channel the requesting instance is awaiting + */ + constructor( + task: string, + params: any, + private readonly redisBridge: RedisBridge, + private readonly originalTaskId: string + ) { + super(task, params); + } + + resolveWithResult(result: PluginTaskResult): void { + this.redisBridge.publishTaskResponse(this.originalTaskId, { + id: this.originalTaskId, + success: true, + data: result.data, + }); + } + + rejectWithError(error: Error): void { + this.redisBridge.publishTaskResponse(this.originalTaskId, { + id: this.originalTaskId, + success: false, + error: error.message, + }); + } +} diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/CljCheckParentheses.ts new file mode 100644 index 0000000000..81e2a9628c --- /dev/null +++ b/mcp/packages/server/src/tools/CljCheckParentheses.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import * as fs from "fs"; + +/** + * Arguments for the FindUnclosedParensTool. + */ +export class CljCheckParenthesesArgs { + static schema = { + file: z.string().min(1).describe("Absolute path to a Clojure/ClojureScript source file"), + }; + + file!: string; +} + +interface OpenDelim { + id: number; + line: number; // 0-based + col: number; // 0-based + char: string; + baselineKey: string; // the baseline key this delimiter owns +} + +interface ParenIssue { + line: number; // 1-based + col: number; // 1-based + char: string; + detectedAtLine?: number; // 1-based line where the stack-state mismatch was observed +} + +/** + * Finds unclosed delimiters in Clojure/ClojureScript source files using a + * stack-state invariant derived from cljfmt formatting conventions. + * + * Invariant: in cljfmt-formatted code, every opening delimiter of type T at + * column C must see the same stack depth each time that (T, C) combination + * occurs. A depth mismatch means delimiters opened between the baseline + * occurrence and the current one were never closed. + * + * The parser correctly handles string literals (including multi-line and escape + * sequences), comment lines, character literals, and regex literals. + */ +export class CljCheckParentheses extends Tool { + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, CljCheckParenthesesArgs.schema); + } + + public getToolName(): string { + return "clj_check_parentheses"; + } + + public getToolDescription(): string { + return "Analyzes a Clojure/ClojureScript source file for unclosed delimiters and reports the area of interest."; + } + + protected async executeCore(args: CljCheckParenthesesArgs): Promise { + const filePath = args.file; + + if (!fs.existsSync(filePath)) { + return new TextResponse(`File not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, "utf-8"); + const issues = analyzeParens(content); + + if (issues.length === 0) { + return new TextResponse("All delimiters are properly balanced."); + } + + const sourceLines = content.split("\n"); + const parts: string[] = [`Found ${issues.length} unclosed delimiter(s):\n`]; + + for (const issue of issues) { + const srcLine = (sourceLines[issue.line - 1] ?? "").trimEnd(); + const pointer = " ".repeat(String(issue.line).length) + " " + " ".repeat(issue.col - 1) + "^"; + + if (issue.detectedAtLine != null) { + const detectedSrcLine = (sourceLines[issue.detectedAtLine - 1] ?? "").trimEnd(); + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col}:\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + + ` Stack-state mismatch detected at line ${issue.detectedAtLine}:\n` + + ` ${issue.detectedAtLine} | ${detectedSrcLine}\n` + ); + } else { + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col} (still open at end of file):\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + ); + } + } + + return new TextResponse(parts.join("\n")); + } +} + +/** + * Analyses delimiter balance in a Clojure/ClojureScript source string. + * + * Algorithm + * --------- + * Maintain a stack of open delimiters and a map from (delimiter-type, column) + * to the stack depth recorded on the first occurrence of that combination. + * + * Each time an opening delimiter of type T appears at column C: + * 1. Look up the key (T, C) in the map. + * 2. If absent, record the current stack depth as the baseline. + * 3. If present, compare the current depth with the baseline. + * - If deeper: the extra stack entries (from baseline depth to current + * depth) are delimiters that should have been closed. Report them. + * - If shallower: more delimiters were closed than opened between the + * baseline and here (over-closed). Update the baseline downward so + * subsequent occurrences don't cascade. + * 4. Push the delimiter onto the stack. + * + * After the full file is processed, any delimiter still on the stack is + * unclosed. If it was already reported via a mismatch, the report includes + * the detection line; otherwise it is reported as open-at-EOF. + */ +function analyzeParens(content: string): ParenIssue[] { + // Precompute line-start offsets for O(1) column lookup. + const lineStarts: number[] = [0]; + for (let i = 0; i < content.length; i++) { + if (content[i] === "\n") lineStarts.push(i + 1); + } + + let nextId = 0; + const stack: OpenDelim[] = []; + + // (type, column) → baseline stack depth. + // Each baseline is owned by the delimiter that established it (stored + // as baselineKey on the stack entry). When that delimiter is popped, + // its baseline is discarded — it was scoped to that delimiter's lifetime. + const baseline: Map = new Map(); + + let inString = false; + let inComment = false; + let escape = false; + let currentLine = 0; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + + // ── Newline ────────────────────────────────────────────────────── + if (ch === "\n") { + inComment = false; + currentLine++; + if (!inString) escape = false; + continue; + } + + // ── Escape: skip next character ────────────────────────────────── + if (escape) { + escape = false; + continue; + } + + // ── Inside comment: skip until newline ─────────────────────────── + if (inComment) continue; + + // ── Inside string literal ──────────────────────────────────────── + if (inString) { + if (ch === "\\") escape = true; + else if (ch === '"') inString = false; + continue; + } + + // ── Outside string / comment ───────────────────────────────────── + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === ";") { + inComment = true; + continue; + } + + // ── Opening delimiter ──────────────────────────────────────────── + if (ch === "(" || ch === "[" || ch === "{") { + const col = i - lineStarts[currentLine]; + const key = `${ch}:${col}`; + const currentDepth = stack.length; + + const recorded = baseline.get(key); + if (recorded !== undefined && currentDepth > recorded) { + // Stack is deeper than expected. The entries from index + // `recorded` to `currentDepth - 1` are unclosed delimiters + // that should have been closed before reaching this + // position. Return immediately — further parsing would be + // against a corrupted stack and only produce cascading noise. + return stack.slice(recorded, currentDepth).map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + detectedAtLine: currentLine + 1, + })); + } + + // Establish or re-establish the baseline for this key, + // owned by this delimiter. Discarded when it is popped. + baseline.set(key, currentDepth); + + stack.push({ + id: nextId++, + line: currentLine, + col, + char: ch, + baselineKey: key, + }); + } + + // ── Closing delimiter ──────────────────────────────────────────── + else if (ch === ")" || ch === "]" || ch === "}") { + if (stack.length > 0) { + const closed = stack.pop()!; + + // The baseline this delimiter owned is no longer valid — + // the context it was recorded in has closed. + baseline.delete(closed.baselineKey); + } + } + } + + // ── EOF: no mismatch was found, but the stack is not empty ────────── + // This happens when the unclosed delimiter has no second occurrence of + // the same (type, column) to compare against (e.g. last form in file). + return stack.map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + })); +} diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts new file mode 100644 index 0000000000..11989c6303 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts @@ -0,0 +1,52 @@ +import { Tool, EmptyToolArgs } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Reports the compiler status of the shadow-cljs `:main` build. + * + * If the most recent build failed, returns the relevant fields of the failure data + * (tag, message, resource name, line, column, etc.); otherwise returns `:ok`. + */ +export class CljsCompilerOutputTool extends Tool { + private static readonly STATUS_CODE = + "(require (quote [shadow.cljs.devtools.api :as shadow])) " + + "(let [fd (-> (shadow/get-worker :main) :state-ref deref :failure-data)] " + + "(if fd (pr-str fd) :ok))"; + + private readonly nreplClient: NreplClient; + + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, EmptyToolArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_compiler_output"; + } + + public getToolDescription(): string { + return ( + "Reports the status of the most recent shadow-cljs `:main` build. " + + "Use this to diagnose compilation errors when needed. For syntax errors, " + + "consider using the clj_check_parentheses tool on the relevant source files." + ); + } + + protected async executeCore(_args: EmptyToolArgs): Promise { + const result = await this.nreplClient.eval(CljsCompilerOutputTool.STATUS_CODE); + + // multiple top-level forms produce multiple values; the build status is the last one + const status = result.values[result.values.length - 1] ?? "nil"; + + const parts: string[] = [status]; + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/tools/CljsReplTool.ts b/mcp/packages/server/src/tools/CljsReplTool.ts new file mode 100644 index 0000000000..bd894caef3 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsReplTool.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Arguments for the CljsReplTool. + */ +export class CljsReplArgs { + static schema = { + code: z.string().min(1, "Code cannot be empty"), + }; + + /** + * The ClojureScript code to evaluate in the frontend runtime. + */ + code!: string; +} + +/** + * A ClojureScript REPL for the Penpot frontend runtime. + * + * This tool provides a persistent REPL session connected to the shadow-cljs nREPL server. + * Definitions, requires, and other state are preserved across calls, enabling iterative + * exploration and manipulation of the running Penpot application. + */ +export class CljsReplTool extends Tool { + private readonly nreplClient: NreplClient; + + /** + * Creates a new CljsReplTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, CljsReplArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_repl"; + } + + public getToolDescription(): string { + return ( + "Persistent ClojureScript REPL in the Penpot frontend runtime (via shadow-cljs nREPL). " + + "Definitions, requires, and state are preserved across calls — use it to build up helpers incrementally. " + + "Multiple top-level expressions per call are supported; each produces a result line." + ); + } + + protected async executeCore(args: CljsReplArgs): Promise { + const result = await this.nreplClient.evalCljs(args.code); + + const parts: string[] = []; + if (result.values.length > 0) { + parts.push(result.values.join("\n")); + } + if (result.out) { + parts.push(`stdout:\n${result.out}`); + } + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + if (parts.length === 0) { + parts.push("nil"); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index 7b8ceed4a6..0d84df9380 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { Tool } from "../Tool"; -import { ImageContent, PNGImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; +import { ImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; import "reflect-metadata"; import { PenpotMcpServer } from "../PenpotMcpServer"; import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask"; +import { createLogger } from "../logger"; import { FileUtils } from "../utils/FileUtils"; +import { Semaphore } from "../utils/Semaphore"; import sharp from "sharp"; /** @@ -49,6 +51,38 @@ export class ExportShapeArgs { * Tool for executing JavaScript code in the Penpot plugin context */ export class ExportShapeTool extends Tool { + /** + * Maximum number of image-export operations that may run concurrently in multi-user mode. + * Configurable via the PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS environment variable; + * defaults to 0, meaning no limit. + * + * When set to a positive value (and combined with the plugin-side per-response cap + * MAX_TASK_RESPONSE_SIZE_REMOTE_MCP, ~15 MB JSON), this caps the in-flight memory + * footprint of image exports at roughly N x cap on the centrally hosted MCP server. + */ + private static readonly MAX_PARALLEL_EXPORTS = parseInt( + process.env.PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS ?? "0", + 10 + ); + + /** + * Gates concurrent export operations across all tool instances (one per session in + * multi-user mode). Static because instances are per-session, but the bound has to + * apply across the whole process. Permits beyond the maximum queue in FIFO order. + * Undefined when MAX_PARALLEL_EXPORTS is non-positive, indicating no limit. + */ + private static readonly parallelismSemaphore: Semaphore | undefined = + ExportShapeTool.MAX_PARALLEL_EXPORTS > 0 + ? new Semaphore("ExportShapeTool", ExportShapeTool.MAX_PARALLEL_EXPORTS) + : undefined; + + static { + createLogger("ExportShapeTool").info( + "Max parallel exports (multi-user mode): %d (0 = unbounded)", + ExportShapeTool.MAX_PARALLEL_EXPORTS + ); + } + /** * Creates a new ExecuteCode tool instance. * @@ -79,6 +113,25 @@ export class ExportShapeTool extends Tool { } protected async executeCore(args: ExportShapeArgs): Promise { + // bound concurrent exports in multi-user mode to keep peak server memory under control; + // in single-user mode (or when no limit is configured) the gate is irrelevant + // and the export runs directly + if (this.mcpServer.isMultiUserMode() && ExportShapeTool.parallelismSemaphore) { + return ExportShapeTool.parallelismSemaphore.withPermit(() => this.exportImage(args)); + } else { + return this.exportImage(args); + } + } + + /** + * Performs the actual image export: requests the image via the plugin and either + * returns it as a tool response or saves it to the requested file path. The bulk + * of the memory pressure (parsed plugin response, decoded image buffer, optional + * re-encoding via sharp) lives here, which is why executeCore gates the call. + * + * @param args - the validated tool arguments + */ + private async exportImage(args: ExportShapeArgs): Promise { // check arguments if (args.filePath) { FileUtils.checkPathIsAbsolute(args.filePath); diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts new file mode 100644 index 0000000000..f55ae194f2 --- /dev/null +++ b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts @@ -0,0 +1,370 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import { TextResponse, ToolResponse } from "../ToolResponse"; +import "reflect-metadata"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; +import { createLogger } from "../logger"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as https from "https"; +import * as http from "http"; + +/** + * Arguments for ImportPenpotFileTool. + */ +export class ImportPenpotFileArgs { + static schema = { + url: z.url().describe("URL of the .penpot file to import."), + }; + + /** URL of the .penpot file to import */ + url!: string; +} + +/** + * Tool for importing a .penpot file into the running Penpot instance. + * + * Downloads the file from the given URL to a temporary location in the frontend's + * static directory, then triggers the import via the Penpot frontend's web worker + * using the ClojureScript REPL. The temporary file is cleaned up after the import + * completes (or fails). + * + * Only available in devenv mode, as it requires the ClojureScript nREPL connection. + */ +export class ImportPenpotFileTool extends Tool { + private static readonly POLL_INTERVAL_MS = 1_000; + private static readonly IMPORT_TIMEOUT_MS = 120_000; + + // assumes cwd is the server package root (same assumption as ConfigurationLoader) + private static readonly PUBLIC_DIR = path.resolve("../../../frontend/resources/public"); + + private static readonly NAVIGATION_HINT = + "To open an imported file in the workspace, use cljs_repl with:\n" + + "(do (require '[app.main.data.common :as dcm])\n" + + " (app.main.store/emit! (dcm/go-to-workspace\n" + + ' :team-id (parse-uuid "")\n' + + ' :file-id (parse-uuid "")\n' + + ' :page-id (parse-uuid ""))))'; + + private readonly log = createLogger("ImportPenpotFileTool"); + private readonly nreplClient: NreplClient; + + /** + * Creates a new ImportPenpotFileTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, ImportPenpotFileArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "import_penpot_file"; + } + + public getToolDescription(): string { + return ( + "Imports a .penpot file into the running Penpot instance from a given URL. " + + "The file is imported into the user's Drafts project. " + + "Returns the name(s) of the imported file(s)." + ); + } + + protected async executeCore(args: ImportPenpotFileArgs): Promise { + // generate a random filename for the temporary file + const randomName = `_import_${crypto.randomUUID()}.penpot`; + const tempFilePath = path.join(ImportPenpotFileTool.PUBLIC_DIR, randomName); + const servePath = `/${randomName}`; + + try { + // download the file + this.log.info("Downloading .penpot file from %s", args.url); + await this.downloadFile(args.url, tempFilePath); + const fileSize = fs.statSync(tempFilePath).size; + this.log.info("Downloaded %d bytes to %s", fileSize, tempFilePath); + + // set up the import via CLJS REPL + const atomName = `import-result-${crypto.randomUUID().slice(0, 8)}`; + const setupCode = this.buildImportCode(atomName, servePath); + + this.log.info("Initiating import via CLJS REPL"); + const setupResult = await this.nreplClient.evalCljs(setupCode); + this.log.debug("CLJS setup result: %s", JSON.stringify(setupResult)); + + // check for immediate errors in the setup + if (setupResult.err) { + throw new Error(`CLJS evaluation error: ${setupResult.err}`); + } + + // poll for the import result + const result = await this.pollForResult(atomName); + return new TextResponse(result); + } finally { + // clean up the temporary file + this.cleanupTempFile(tempFilePath); + } + } + + /** + * Builds the ClojureScript code that fetches the file from the static directory, + * creates a blob URL, and triggers the import via the web worker. + * + * @param atomName - unique name for the result atom + * @param servePath - the URL path to fetch the file from (same-origin) + * @returns the ClojureScript code string + */ + private buildImportCode(atomName: string, servePath: string): string { + // escape for embedding in a CLJS string + const escapedPath = servePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const escapedAtom = atomName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.worker :as mw]) + (require '[app.common.uuid :as uuid]) + (require '[beicon.v2.core :as rx]) + + (def ${escapedAtom} (atom {:status :pending})) + + (let [project-id (->> @st/state :projects vals (filter :is-default) first :id) + file-ids-before (set (keys (:files @st/state)))] + (-> (js/fetch "${escapedPath}") + (.then (fn [resp] + (when-not (.-ok resp) + (reset! ${escapedAtom} {:status :error :error (str "Fetch failed: " (.-status resp))}) + (throw (js/Error. (str "Fetch failed: " (.-status resp))))) + (.blob resp))) + (.then (fn [blob] + (let [uri (js/URL.createObjectURL blob) + file-id (uuid/next) + entries [{:file-id file-id + :name "import" + :type :binfile-v3 + :uri uri}]] + (->> (mw/ask-many! + {:cmd :import-files + :project-id project-id + :files entries + :features (get @st/state :features)}) + (rx/subs! + (fn [msg] + (when (= :finish (:status msg)) + (reset! ${escapedAtom} + {:status :success + :file-ids-before file-ids-before}))) + (fn [err] + (reset! ${escapedAtom} {:status :error :error (str err)})) + (fn [] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error "Stream completed without success message"})))))))) + (.catch (fn [err] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error (str err)})))))) + + :initiated) + `; + } + + /** + * Builds the ClojureScript code that resolves the imported file details. + * + * Refreshes the dashboard, diffs the file list against the pre-import snapshot, + * and for each new file fetches the first page-id via the backend API. + * + * @param atomName - the atom holding the import result (including :file-ids-before) + * @param resultAtomName - the atom to store the final file details in + * @returns the ClojureScript code string + */ + private buildResolveCode(atomName: string, resultAtomName: string): string { + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.repo :as rp]) + (require '[app.main.data.dashboard :as dd]) + (require '[beicon.v2.core :as rx]) + + (def ${resultAtomName} (atom {:status :pending})) + + (let [file-ids-before (:file-ids-before @${atomName}) + team-id (:current-team-id @st/state)] + ;; refresh dashboard files + (st/emit! (dd/fetch-recent-files)) + ;; wait a moment for the state to update, then resolve + (js/setTimeout + (fn [] + (let [all-files (vals (:files @st/state)) + new-files (remove #(contains? file-ids-before (:id %)) all-files) + file-count (count new-files)] + (if (zero? file-count) + (reset! ${resultAtomName} {:status :success :files []}) + ;; fetch page-ids for each new file + (let [remaining (atom file-count) + results (atom [])] + (doseq [f new-files] + (->> (rp/cmd! :get-file {:id (:id f) :features (get @st/state :features)}) + (rx/subs! + (fn [file-data] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :page-id (str (first (get-in file-data [:data :pages])))}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results}))) + (fn [err] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :error (str err)}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results})))))))))) + 500)) + + :initiated) + `; + } + + /** + * Polls the CLJS atom for the import result until it succeeds, fails, or times out. + * On success, resolves the imported file details (server-side IDs, names, page-ids). + * + * @param atomName - the name of the atom to poll + * @returns a JSON string with the imported file details + */ + private async pollForResult(atomName: string): Promise { + const startTime = Date.now(); + + // phase 1: wait for the import to complete + while (Date.now() - startTime < ImportPenpotFileTool.IMPORT_TIMEOUT_MS) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${atomName})`); + const resultStr = pollResult.values.join(""); + this.log.debug(`Poll result: ${resultStr}`); + + if (resultStr.includes(":success")) { + this.log.info("Import succeeded, resolving file details..."); + return await this.resolveImportedFiles(atomName); + } else if (resultStr.includes(":error")) { + this.log.error(`Import failed: ${resultStr}`); + throw new Error(`Import failed: ${resultStr}`); + } + } + + throw new Error(`Import timed out after ${ImportPenpotFileTool.IMPORT_TIMEOUT_MS / 1000} seconds`); + } + + /** + * After a successful import, resolves the actual server-side file details + * by diffing the dashboard file list and fetching page IDs. + * + * @param atomName - the atom holding the import result with :file-ids-before + * @returns a JSON string with the imported file details + */ + private async resolveImportedFiles(atomName: string): Promise { + const resultAtomName = `import-details-${crypto.randomUUID().slice(0, 8)}`; + const resolveCode = this.buildResolveCode(atomName, resultAtomName); + + await this.nreplClient.evalCljs(resolveCode); + + // poll the result atom + const startTime = Date.now(); + const resolveTimeoutMs = 15_000; + + while (Date.now() - startTime < resolveTimeoutMs) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${resultAtomName})`); + const resultStr = pollResult.values.join(""); + + if (resultStr.includes(":success")) { + this.log.info("File details resolved"); + return resultStr + "\n\n" + ImportPenpotFileTool.NAVIGATION_HINT; + } + } + + this.log.warn("Timed out resolving file details, returning basic success"); + return "Import succeeded but could not resolve file details."; + } + + /** + * Downloads a file from a URL to a local path. + * + * @param url - the URL to download from + * @param destPath - the local file path to write to + */ + private downloadFile(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const file = fs.createWriteStream(destPath); + + const request = client.get(url, (response) => { + // handle redirects + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + file.close(); + fs.unlinkSync(destPath); + this.downloadFile(response.headers.location, destPath).then(resolve, reject); + return; + } + + if (response.statusCode && response.statusCode !== 200) { + file.close(); + fs.unlinkSync(destPath); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + response.pipe(file); + file.on("finish", () => { + file.close(); + resolve(); + }); + }); + + request.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`Download error: ${err.message}`)); + }); + + file.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`File write error: ${err.message}`)); + }); + }); + } + + /** + * Removes the temporary file, logging but not throwing on failure. + */ + private cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + this.log.info("Cleaned up temporary file: %s", filePath); + } + } catch (err) { + this.log.warn("Failed to clean up temporary file %s: %s", filePath, err); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts new file mode 100644 index 0000000000..29e8bea460 --- /dev/null +++ b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; + +/** + * Arguments for the {@link ReadTaigaIssueTool}. + */ +export class ReadTaigaIssueArgs { + static schema = { + issueNumber: z + .number() + .int() + .positive() + .describe( + "The Penpot issue number as it appears in Taiga URLs, " + + "e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177" + ), + }; + + /** + * The Penpot issue number as it appears in Taiga issue URLs + * (e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177). + */ + issueNumber!: number; +} + +/** + * Represents a file attachment on a Taiga issue. + */ +interface TaigaAttachment { + filename: string; + size: number; + url: string; +} + +/** + * Represents a comment on a Taiga issue. + */ +interface TaigaComment { + username: string; + comment: string; +} + +/** + * The resolved issue data returned by the tool. + */ +interface TaigaIssueData { + subject: string; + description: string; + status: string; + attachments: TaigaAttachment[]; + comments: TaigaComment[]; +} + +/** + * Tool for reading Penpot issues from the Taiga project tracker. + * + * Resolves a Penpot issue number to its internal Taiga ID and retrieves the issue's + * subject, description, status, attachments, and comments via the Taiga REST API. + */ +export class ReadTaigaIssueTool extends Tool { + private static readonly TAIGA_API_BASE = "https://api.taiga.io/api/v1"; + + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, ReadTaigaIssueArgs.schema); + } + + public getToolName(): string { + return "read_taiga_issue"; + } + + public getToolDescription(): string { + return "Reads a Penpot issue from the Taiga project tracker, returning its subject, description, status, attachments, and comments."; + } + + protected async executeCore(args: ReadTaigaIssueArgs): Promise { + const { projectId, issueId } = await this.resolveIssue(args.issueNumber); + const issueData = await this.fetchIssueData(projectId, issueId); + return new TextResponse(JSON.stringify(issueData, null, 2)); + } + + /** + * Resolves a Penpot issue number to the internal Taiga project and issue IDs. + */ + private async resolveIssue(issueNumber: number): Promise<{ projectId: number; issueId: number }> { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/resolver?project=penpot&issue=${issueNumber}`; + const data = await this.fetchJson(url); + return { projectId: data.project, issueId: data.issue }; + } + + /** + * Fetches the full issue data including details, attachments, and comments. + */ + private async fetchIssueData(projectId: number, issueId: number): Promise { + // fetch issue details, attachments, and history in parallel + const [details, attachments, comments] = await Promise.all([ + this.fetchIssueDetails(issueId), + this.fetchAttachments(projectId, issueId), + this.fetchComments(issueId), + ]); + + return { + subject: details.subject, + description: details.description ?? "", + status: details.status_extra_info?.name ?? "Unknown", + attachments, + comments, + }; + } + + /** + * Fetches the core issue details from the Taiga API. + */ + private async fetchIssueDetails(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/${issueId}`; + return this.fetchJson(url); + } + + /** + * Fetches the attachments for an issue. + */ + private async fetchAttachments(projectId: number, issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/attachments?project=${projectId}&object_id=${issueId}`; + const data: any[] = await this.fetchJson(url); + return data.map((a) => ({ + filename: a.name, + size: a.size, + url: a.url, + })); + } + + /** + * Fetches comments from the issue history. + * + * History entries that have a non-empty `comment` field are treated as comments. + */ + private async fetchComments(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/history/issue/${issueId}`; + const history: any[] = await this.fetchJson(url); + return history + .filter((entry) => entry.comment && entry.comment.trim().length > 0) + .map((entry) => ({ + username: entry.user?.username ?? "unknown", + comment: entry.comment, + })); + } + + /** + * Performs a GET request and returns the parsed JSON response. + * + * @throws Error if the HTTP response status is not OK + */ + private async fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Taiga API request failed: ${response.status} ${response.statusText} (${url})`); + } + return response.json(); + } +} diff --git a/mcp/packages/server/src/types/nrepl-client.d.ts b/mcp/packages/server/src/types/nrepl-client.d.ts new file mode 100644 index 0000000000..21ed53fb72 --- /dev/null +++ b/mcp/packages/server/src/types/nrepl-client.d.ts @@ -0,0 +1,53 @@ +declare module "nrepl-client" { + import type { Socket } from "net"; + + interface NreplConnection extends Socket { + /** + * Evaluates the given Clojure expression on the nREPL server. + * + * @param code - the Clojure expression to evaluate + * @param callback - called with an error or array of response messages + */ + eval(code: string, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Sends a raw nREPL message to the server. + */ + send(message: Record, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Clones the current session, creating a new session that inherits the current state. + */ + clone(callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Closes the current session. + */ + close(callback: (err: Error | null, result: NreplMessage[]) => void): void; + } + + interface NreplMessage { + id?: string; + session?: string; + "new-session"?: string; + ns?: string; + value?: string; + out?: string; + err?: string; + ex?: string; + status?: string[]; + } + + interface ConnectOptions { + port: number; + host?: string; + } + + /** + * Creates a connection to an nREPL server. + */ + function connect(options: ConnectOptions): NreplConnection; + + export default { connect }; + export type { NreplConnection, NreplMessage, ConnectOptions }; +} diff --git a/mcp/packages/server/src/utils/Semaphore.ts b/mcp/packages/server/src/utils/Semaphore.ts new file mode 100644 index 0000000000..e88a63db36 --- /dev/null +++ b/mcp/packages/server/src/utils/Semaphore.ts @@ -0,0 +1,69 @@ +import { createLogger } from "../logger"; + +/** + * Counting semaphore for bounding the parallelism of asynchronous operations. + * + * Calls in excess of the configured maximum are queued in FIFO order and + * proceed once an earlier holder has released its permit. + */ +export class Semaphore { + private static readonly logger = createLogger("Semaphore"); + + private available: number; + private readonly waiters: Array<() => void> = []; + + /** + * @param name - identifier used in log messages so the source of contention is recognisable + * @param maxConcurrent - the maximum number of permits that may be held simultaneously + */ + constructor( + private readonly name: string, + maxConcurrent: number + ) { + if (maxConcurrent < 1) { + throw new Error(`maxConcurrent must be at least 1; got ${maxConcurrent}`); + } + this.available = maxConcurrent; + } + + /** + * Acquires a permit, runs the given function, and releases the permit when it + * settles - including on rejection. Queues if no permit is currently available. + * + * @param fn - the function to run while holding the permit + * @returns the value returned by fn + */ + public async withPermit(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.available > 0) { + this.available--; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + Semaphore.logger.info( + "Semaphore '%s' saturated; request queued (%d waiting)", + this.name, + this.waiters.length + ); + }); + } + + private release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + // pass the permit directly to the next waiter without touching `available` + next(); + } else { + this.available++; + } + } +} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index c59ac34c3f..16a7487e30 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -38,10 +38,10 @@ importers: version: 5.9.3 vite: specifier: ^7.0.8 - version: 7.3.1(@types/node@20.19.30) + version: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) vite-live-preview: specifier: ^0.3.2 - version: 0.3.2(vite@7.3.1(@types/node@20.19.30)) + version: 0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)) packages/server: dependencies: @@ -57,9 +57,15 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + ioredis: + specifier: ^5.6.0 + version: 5.11.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 + nrepl-client: + specifier: ^0.3.0 + version: 0.3.0 penpot-mcp: specifier: file:.. version: packages@file:packages @@ -109,6 +115,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.30)(typescript@5.9.3) + tsx: + specifier: ^4.22.3 + version: 4.22.3 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -139,6 +148,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -151,6 +166,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -163,6 +184,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -175,6 +202,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -187,6 +220,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -199,6 +238,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -211,6 +256,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -223,6 +274,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -235,6 +292,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -247,6 +310,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -259,6 +328,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -271,6 +346,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -283,6 +364,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -295,6 +382,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -307,6 +400,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -319,6 +418,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -331,6 +436,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -343,6 +454,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -355,6 +472,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -367,6 +490,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -379,6 +508,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -391,6 +526,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -403,6 +544,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -415,6 +562,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -427,6 +580,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -439,6 +598,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -598,6 +763,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -881,6 +1049,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -915,6 +1086,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -978,6 +1153,10 @@ packages: supports-color: optional: true + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1029,6 +1208,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1149,6 +1333,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.11.0: + resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1221,6 +1409,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nrepl-client@0.3.0: + resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1328,6 +1519,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.1.14: resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} @@ -1420,6 +1619,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1476,6 +1678,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1618,156 +1825,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -1868,6 +2153,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.10.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2092,6 +2379,8 @@ snapshots: atomic-sleep@1.0.0: {} + bencode@2.0.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2139,6 +2428,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2189,6 +2480,8 @@ snapshots: dependencies: ms: 2.1.3 + denque@2.1.0: {} + depd@2.0.0: {} detect-libc@2.1.2: {} @@ -2277,6 +2570,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -2408,6 +2730,18 @@ snapshots: inherits@2.0.4: {} + ioredis@5.11.0: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2452,6 +2786,11 @@ snapshots: negotiator@1.0.0: {} + nrepl-client@0.3.0: + dependencies: + bencode: 2.0.3 + tree-kill: 1.2.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -2564,6 +2903,12 @@ snapshots: real-require@0.2.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.1.14: {} require-directory@2.1.1: {} @@ -2725,6 +3070,8 @@ snapshots: split2@4.2.0: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} string-width@4.2.3: @@ -2780,6 +3127,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -2798,7 +3151,7 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)): + vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)): dependencies: '@commander-js/extra-typings': 12.1.0(commander@12.1.0) '@types/ansi-html': 0.0.0 @@ -2810,14 +3163,14 @@ snapshots: debug: 4.4.3 escape-goat: 4.0.0 p-defer: 4.0.1 - vite: 7.3.1(@types/node@20.19.30) + vite: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) ws: 8.19.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - vite@7.3.1(@types/node@20.19.30): + vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -2828,6 +3181,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.30 fsevents: 2.3.3 + tsx: 4.22.3 which@2.0.2: dependencies: diff --git a/mcp/scripts/start-mcp-devenv b/mcp/scripts/start-mcp-devenv new file mode 100755 index 0000000000..62e957a9eb --- /dev/null +++ b/mcp/scripts/start-mcp-devenv @@ -0,0 +1,6 @@ +#!/bin/sh + +# This starts the MCP server in a configuration for Penpot development +# (assuming devenv) + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_DEVENV=true pnpm run bootstrap