Backport mcp package changes from develop

This commit is contained in:
Andrey Antukh 2026-06-08 09:59:33 +02:00
parent bae4d23c67
commit 4f852e33bf
25 changed files with 2497 additions and 193 deletions

View File

@ -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

View File

@ -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: []

View File

@ -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

View File

@ -24,8 +24,10 @@ export class Task<TParams = any> {
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<TParams = any> {
};
// 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;
}

View File

@ -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);
}

View File

@ -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<string | { id: string; type?: string; status?: string; task: string; params: any }>((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<string | { id: string; type?: string; status?: string; task:
});
}
// Initiate connection to remote MCP server (if enabled)
if (mcp) {
if (isIntegratedRemoteMcp) {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),

View File

@ -269,8 +269,9 @@ Using library components:
* create a new instance of the component on the current page:
`const instance: Shape = component.instance();`
This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape:
- After instantiation, modify the instance's properties as desired.
- Get a reference to the component an instance was created from via `instance.component()`.
* get the reference to the main instance (shape that serves as the source for new instances):
`const mainShape: Shape = component.mainInstance();`
Adding a component to a library:
@ -295,6 +296,7 @@ Variants are a system for grouping related component versions along named proper
- `properties: string[]` (ordered list of property names); `addProperty(): void`, `renameProperty(pos, name)`, `currentValues(property)`
- `variantComponents(): LibraryVariantComponent[]`
* `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true.
- `variants: Variants`
- `variantProps: { [property: string]: string }` (this component's value for each property)
- `variantError` (non-null if e.g. two variants share the same combination of property values)
- `setVariantProperty(pos, value)`
@ -313,6 +315,7 @@ Use `variantContainer.appendChild(mainInstance)` to move a component's main inst
**Using Variants**:
- `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same.
- To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`.
- Given a variant component instance, access the component it was instantiated from via `instance.component()` and the `Variants` instance via `instance.component().variants`.
# Design Tokens

View File

@ -5,13 +5,14 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp",
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:nrepl-client --external:ioredis",
"build": "pnpm run build:server && node scripts/copy-resources.js",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"start": "node dist/index.js",
"start:multi-user": "node dist/index.js --multi-user",
"start:dev": "node --import ts-node/register src/index.ts",
"start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user",
"test:integration:export-sema": "tsx scripts/integration-test-export-image-semaphore.ts",
"types:check": "tsc --noEmit",
"clean": "rm -rf dist/"
},
@ -28,7 +29,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"express": "^5.1.0",
"ioredis": "^5.6.0",
"js-yaml": "^4.1.1",
"nrepl-client": "^0.3.0",
"penpot-mcp": "file:..",
"pino": "^9.10.0",
"pino-loki": "^2.6.0",
@ -39,14 +42,15 @@
"zod": "^4.3.6"
},
"devDependencies": {
"cross-env": "^7.0.3",
"@penpot/mcp-common": "workspace:../common",
"@types/express": "^4.17.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/ws": "^8.5.10",
"cross-env": "^7.0.3",
"esbuild": "^0.25.0",
"ts-node": "^10.9.2",
"tsx": "^4.22.3",
"typescript": "^5.0.0"
},
"ts-node": {

View File

@ -0,0 +1,116 @@
/**
* One-off integration test for the parallelism bound around image exports.
*
* Setup:
* - Stubs ExportShapeTool.exportImage to sleep SLEEP_MS instead of doing real work,
* so no actual plugin connection is needed.
* - Replaces the static parallelism semaphore with one of size N.
* - Starts a PenpotMcpServer in multi-user mode on three random free ports.
* - Fires M > 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<number> {
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<unknown> {
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<void> {
// 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<TextResponse> => {
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);
});

View File

@ -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<NreplEvalResult> {
this.logger.debug("Evaluating Clojure expression: %s", code);
const conn = await this.ensureConnection();
const sessionId = await this.ensureSession(conn);
return new Promise<NreplEvalResult>((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<NreplEvalResult> {
// 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<void> {
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<NreplConnection> {
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<NreplConnection>((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<string> {
if (this.sessionId) {
return this.sessionId;
}
this.logger.info("Cloning new nREPL session");
return new Promise<string>((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,
};
}
}

View File

@ -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<SessionContext>();
private readonly streamableTransports: Record<string, StreamableSession> = {};
private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {};
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<typeof setInterval> | 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<void> {
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");
}

View File

@ -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<WebSocket, ClientConnection> = new Map();
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
private readonly pendingTasks: Map<string, PluginTask<any, any>> = new Map();
private readonly pendingTasks: Map<string, AbstractPluginTask<any, any>> = new Map();
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = 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<TResult extends PluginTaskResult<any>>(
task: PluginTask<any, TResult>
): Promise<TResult> {
// 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<any, any>, 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)));
}
}
}

View File

@ -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<TParams = any, TResult extends PluginTaskResult<any> = PluginTaskResult<any>> {
export abstract class AbstractPluginTask<TParams = any, TResult extends PluginTaskResult<any> = PluginTaskResult<any>> {
/**
* Unique identifier for request/response correlation.
*/
@ -34,6 +38,66 @@ export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult
*/
public readonly params: TParams;
/**
* Creates a new plugin task instance.
*
* @param task - The name of the task to execute
* @param params - The parameters for task execution
*/
protected constructor(task: string, params: TParams) {
this.id = randomUUID();
this.task = task;
this.params = params;
}
/**
* Serializes the task to a request message for transmission to the plugin.
*
* @returns The request message containing ID, task name, and parameters
*/
toRequest(): PluginTaskRequest {
return {
id: this.id,
task: this.task,
params: this.params,
};
}
/**
* Settles the task successfully with the given result.
*
* Called by the response-correlation machinery when the plugin reports success
* for the task with the matching ID.
*
* @param result - The task execution result
*/
abstract resolveWithResult(result: TResult): void;
/**
* Settles the task unsuccessfully with the given error.
*
* Called by the response-correlation machinery when task execution fails
* or times out.
*
* @param error - The error that occurred during task execution
*/
abstract rejectWithError(error: Error): void;
}
/**
* A locally-awaited plugin task.
*
* The task's outcome is exposed as an in-process promise (see {@link getResultPromise}),
* which the caller awaits to obtain the result. This is the task type used by tools that
* execute operations on the plugin and consume the result directly.
*
* @template TParams - The strongly-typed parameters for this task
* @template TResult - The expected result type from task execution
*/
export class PluginTask<
TParams = any,
TResult extends PluginTaskResult<any> = PluginTaskResult<any>,
> extends AbstractPluginTask<TParams, TResult> {
/**
* Promise that resolves when the task execution completes.
*/
@ -50,15 +114,13 @@ export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult
private rejectResult?: (error: Error) => 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<TResult>((resolve, reject) => {
this.resolveResult = resolve;
this.rejectResult = reject;
@ -77,14 +139,6 @@ export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult
return this.result;
}
/**
* Resolves the task with the given result.
*
* This method should be called when a task response is received
* from the plugin with matching ID.
*
* @param result - The task execution result
*/
resolveWithResult(result: TResult): void {
if (!this.resolveResult) {
throw new Error("Result promise not initialized");
@ -92,31 +146,10 @@ export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult
this.resolveResult(result);
}
/**
* Rejects the task with the given error.
*
* This method should be called when task execution fails
* or times out.
*
* @param error - The error that occurred during task execution
*/
rejectWithError(error: Error): void {
if (!this.rejectResult) {
throw new Error("Result promise not initialized");
}
this.rejectResult(error);
}
/**
* Serializes the task to a request message for WebSocket transmission.
*
* @returns The request message containing ID, task name, and parameters
*/
toRequest(): PluginTaskRequest {
return {
id: this.id,
task: this.task,
params: this.params,
};
}
}

View File

@ -0,0 +1,182 @@
import Redis from "ioredis";
import { PluginTaskRequest, PluginTaskResponse } from "@penpot/mcp-common";
import { createLogger } from "./logger";
/**
* Channel name prefixes for the task request/response pub/sub protocol.
*
* Request channels are keyed by user token (one per connected plugin); response
* channels are keyed by the task ID, so that only the instance that issued a given
* request receives its response.
*/
const TASK_REQUEST_CHANNEL_PREFIX = "penpot.mcp.task.req.";
const TASK_RESPONSE_CHANNEL_PREFIX = "penpot.mcp.task.res.";
/**
* Handler invoked for a task request arriving on a subscribed request channel.
*/
export type TaskRequestHandler = (request: PluginTaskRequest) => void;
/**
* Handler invoked for a task response arriving on a subscribed response channel.
*/
export type TaskResponseHandler = (response: PluginTaskResponse<any>) => 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<string, (rawMessage: string) => 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<void> {
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<any>);
} 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<void> {
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<any>): 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<void> {
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<void> {
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<void> {
await this.subscriber.quit();
await this.publisher.quit();
}
}

View File

@ -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<any, PluginTaskResult<any>> {
/**
* 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<any>): 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,
});
}
}

View File

@ -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<CljCheckParenthesesArgs> {
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<ToolResponse> {
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<string, number> = 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,
}));
}

View File

@ -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<EmptyToolArgs> {
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<ToolResponse> {
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"));
}
}

View File

@ -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<CljsReplArgs> {
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<ToolResponse> {
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"));
}
}

View File

@ -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<ExportShapeArgs> {
/**
* 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<ExportShapeArgs> {
}
protected async executeCore(args: ExportShapeArgs): Promise<ToolResponse> {
// 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<ToolResponse> {
// check arguments
if (args.filePath) {
FileUtils.checkPathIsAbsolute(args.filePath);

View File

@ -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<ImportPenpotFileArgs> {
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 "<team-id>")\n' +
' :file-id (parse-uuid "<file-id>")\n' +
' :page-id (parse-uuid "<page-id>"))))';
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<ToolResponse> {
// 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<string> {
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<string> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@ -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<ReadTaigaIssueArgs> {
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<ToolResponse> {
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<TaigaIssueData> {
// 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<any> {
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<TaigaAttachment[]> {
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<TaigaComment[]> {
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<any> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Taiga API request failed: ${response.status} ${response.statusText} (${url})`);
}
return response.json();
}
}

View File

@ -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<string, unknown>, 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 };
}

View File

@ -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<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
private acquire(): Promise<void> {
if (this.available > 0) {
this.available--;
return Promise.resolve();
}
return new Promise<void>((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++;
}
}
}

364
mcp/pnpm-lock.yaml generated
View File

@ -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:

6
mcp/scripts/start-mcp-devenv Executable file
View File

@ -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