Dr. Dominik Jain 14b53ecfec
Bound MCP memory consumption by limiting parallel exports & response size (#9748)
*  Bound the size of plugin task responses

When using the integrated remote MCP server, bound response size.
All responses are passed to LLMs, which themselves impose bounds.
This is a measure to bound memory usage in the centrally provided
MCP server.

GitHub #9493

*  Bound parallelism in ExportShapeTool

Use an integer semaphore to bound parallel requests to this
memory-intensive tool, thus bounding memory usage.

GitHub #9493

*  Add (manual) integration test script for ExportShapeTool parallelism

Add dependency tsx to facilitate executions.

GitHub #9493

*  Make number of parallel export requests configurable in ExportShapeTool

Use env var PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS to configure
the maximum number of requests in multi-user mode (default 0, no limit).
2026-05-19 19:37:29 +02:00

237 lines
8.5 KiB
TypeScript

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 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;
const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement;
const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement;
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement;
const versionWarningEl = document.getElementById("version-warning") as HTMLElement;
const versionWarningTextEl = document.getElementById("version-warning-text") as HTMLElement;
/**
* Updates the status pill and button visibility based on connection state.
*
* @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error")
* @param label - human-readable label to display inside the pill
*/
function updateConnectionStatus(code: string, label: string): void {
if (statusPill) {
statusPill.dataset.status = code;
}
if (statusText) {
statusText.textContent = label;
}
const isConnected = code === "connected";
if (connectBtn) connectBtn.hidden = isConnected;
if (disconnectBtn) disconnectBtn.hidden = !isConnected;
parent.postMessage(
{
type: "update-connection-status",
status: code,
},
"*"
);
}
/**
* Updates the "Current task" display with the currently executing task name.
*
* @param taskName - the task name to display, or null to reset to "---"
*/
function updateCurrentTask(taskName: string | null): void {
if (currentTaskEl) {
currentTaskEl.textContent = taskName ?? "---";
}
if (taskName === null) {
updateExecutedCode(null);
}
}
/**
* Updates the executed code textarea with the last code run during task execution.
*
* @param code - the code string to display, or null to clear
*/
function updateExecutedCode(code: string | null): void {
if (executedCodeEl) {
executedCodeEl.value = code ?? "";
}
if (copyCodeBtn) {
copyCodeBtn.disabled = !code;
}
}
/**
* Sends a task response back to the MCP server via WebSocket.
*
* @param response - The response containing task ID and result
*/
function sendTaskResponse(response: any): void {
if (ws && ws.readyState === WebSocket.OPEN) {
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");
}
}
/**
* Establishes a WebSocket connection to the MCP server.
*/
function connectToMcpServer(baseUrl?: string, token?: string): void {
if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("connected", "Connected");
return;
}
try {
let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL;
let wsError: unknown | undefined;
if (token) {
wsUrl += `?userToken=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
updateConnectionStatus("connecting", "Connecting...");
ws.onopen = () => {
setTimeout(() => {
if (ws) {
console.log("Connected to MCP server");
updateConnectionStatus("connected", "Connected");
}
}, 100);
};
ws.onmessage = (event) => {
try {
console.log("Received from MCP server:", event.data);
const request = JSON.parse(event.data);
// Track the current task received from the MCP server
if (request.task) {
updateCurrentTask(request.task);
updateExecutedCode(request.params?.code ?? null);
}
// Forward the task request to the plugin for execution
parent.postMessage(request, "*");
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onclose = (event: CloseEvent) => {
// If we've send the error update we don't send the disconnect as well
if (!wsError) {
console.log("Disconnected from MCP server");
const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected";
updateConnectionStatus("disconnected", label);
updateCurrentTask(null);
}
ws = null;
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
wsError = error;
// note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("error", "Connection error");
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const reason = error instanceof Error ? error.message : undefined;
const label = reason ? `Connection failed: ${reason}` : "Connection failed";
updateConnectionStatus("error", label);
}
}
copyCodeBtn?.addEventListener("click", () => {
const code = executedCodeEl?.value;
if (!code) return;
navigator.clipboard.writeText(code).then(() => {
copyCodeBtn.classList.add("copied");
setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500);
});
});
connectBtn?.addEventListener("click", () => {
connectToMcpServer();
});
disconnectBtn?.addEventListener("click", () => {
ws?.close();
});
// 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);
}
if (event.data.type === "version-mismatch") {
if (versionWarningEl && versionWarningTextEl) {
versionWarningTextEl.innerHTML =
`<b>Version mismatch detected</b>: This version of the MCP server is intended for Penpot ` +
`${event.data.mcpVersion} while the current version is ${event.data.penpotVersion}. ` +
`Executions may not work or produce suboptimal results.`;
versionWarningEl.hidden = false;
}
}
if (event.data.type === "stop-server") {
ws?.close();
} else if (event.data.source === "penpot") {
document.body.dataset.theme = event.data.theme;
} else if (event.data.type === "task-response") {
// Forward task response back to MCP server
sendTaskResponse(event.data.response);
}
});
parent.postMessage({ type: "ui-initialized" }, "*");