mirror of
https://github.com/penpot/penpot.git
synced 2026-06-09 08:52:05 +00:00
⏪ Backport mcp package changes from develop
This commit is contained in:
parent
bae4d23c67
commit
4f852e33bf
@ -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
|
||||
|
||||
@ -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: []
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
217
mcp/packages/server/src/NreplClient.ts
Normal file
217
mcp/packages/server/src/NreplClient.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
182
mcp/packages/server/src/RedisBridge.ts
Normal file
182
mcp/packages/server/src/RedisBridge.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
56
mcp/packages/server/src/RemotePluginTask.ts
Normal file
56
mcp/packages/server/src/RemotePluginTask.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
242
mcp/packages/server/src/tools/CljCheckParentheses.ts
Normal file
242
mcp/packages/server/src/tools/CljCheckParentheses.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
52
mcp/packages/server/src/tools/CljsCompilerOutputTool.ts
Normal file
52
mcp/packages/server/src/tools/CljsCompilerOutputTool.ts
Normal 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"));
|
||||
}
|
||||
}
|
||||
75
mcp/packages/server/src/tools/CljsReplTool.ts
Normal file
75
mcp/packages/server/src/tools/CljsReplTool.ts
Normal 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"));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
370
mcp/packages/server/src/tools/ImportPenpotFileTool.ts
Normal file
370
mcp/packages/server/src/tools/ImportPenpotFileTool.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
163
mcp/packages/server/src/tools/ReadTaigaIssueTool.ts
Normal file
163
mcp/packages/server/src/tools/ReadTaigaIssueTool.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
53
mcp/packages/server/src/types/nrepl-client.d.ts
vendored
Normal file
53
mcp/packages/server/src/types/nrepl-client.d.ts
vendored
Normal 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 };
|
||||
}
|
||||
69
mcp/packages/server/src/utils/Semaphore.ts
Normal file
69
mcp/packages/server/src/utils/Semaphore.ts
Normal 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
364
mcp/pnpm-lock.yaml
generated
@ -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
6
mcp/scripts/start-mcp-devenv
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user