alonso.torres 9517f2ac94 ♻️ Type the MCP plugin with the repo plugin types
Link the MCP plugin to plugins/libs/plugin-types instead of the
published 1.5.0 package, which lacks PenpotMgmt. The repo types
declare the penpotMgmt global, so the inline import in index.d.ts
goes away and the plugin checks against the API it runs with.

Type Context.management as PenpotMgmt without on/off, so new
management methods need no extra listing.

Mark the background plugin mock in load-plugin.spec.ts as
NonNullable: createPlugin can return undefined, and the build
type-check failed on it.
2026-10-07 15:21:16 +02:00

193 lines
6.9 KiB
TypeScript

import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
import { formatTaskError } from "./ErrorUtils";
import type { PluginConnectionInit } from "../../common/src";
import type { WorkspaceContext } from "@penpot/plugin-types";
/**
* 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;
/**
* the management API, which Penpot only exposes to plugins running with global scope;
* null otherwise (e.g. in Penpot versions without global plugins)
*/
const management = typeof penpotMgmt === "undefined" ? null : penpotMgmt;
/**
* Extracts the major.minor.patch prefix from a version string.
*
* @param version - a version string starting with major.minor.patch
* @returns the major.minor.patch prefix, or the original string if it does not match
*/
function extractVersionPrefix(version: string): string {
const match = version.match(/^(\d+\.\d+\.\d+)/);
return match ? match[1] : version;
}
let uiInitialized = false;
/**
* Registry of all available task handlers.
*/
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, {
width: 236,
height: 210,
hidden: isIntegratedRemoteMcp,
} as any);
// Register message handlers
penpot.ui.onMessage<
string | { id: string; type?: string; status?: string; sessionId?: string; task: string; params: any }
>((message) => {
if (typeof message === "object" && message.type === "ui-initialized") {
uiInitialized = true;
// 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);
console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`);
const isLocalPenpotVersion = penpotVersionPrefix == "0.0.0";
if (penpotVersionPrefix !== mcpVersionPrefix && !isLocalPenpotVersion) {
penpot.ui.sendMessage({
type: "version-mismatch",
mcpVersion: mcpVersionPrefix,
penpotVersion: penpotVersionPrefix,
});
}
// connect only when requested in this tab
if (isIntegratedRemoteMcp && mcp?.isConnectionRequested()) {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
}
} else if (typeof message === "object" && message.type === "connection-metadata-request") {
const workspace = currentWorkspace();
if (message.sessionId) {
const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = {
type: "initialize",
session: {
sessionId: message.sessionId,
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
penpotUserSessionId: penpot.currentUser.sessionId,
};
penpot.ui.sendMessage(initialization);
}
} else if (typeof message === "object" && message.type === "context-request") {
sendWorkspaceContext();
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown", message.sessionId);
} else if (typeof message === "object" && message.task && message.id) {
// Handle plugin tasks submitted by the MCP server
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
});
}
});
/**
* Provides the workspace metadata of this tab.
* Without the management API, the metadata is derived from the open file.
*/
function currentWorkspace(): WorkspaceContext {
if (management) {
return management.workspace;
}
const file = penpot.currentFile;
return {
status: file ? "ready" : "none",
fileId: file?.id ?? null,
fileName: file?.name ?? null,
teamId: null,
};
}
/** Sends the current workspace metadata without replacing the connection. */
function sendWorkspaceContext(): void {
const workspace = currentWorkspace();
penpot.ui.sendMessage({
type: "context-update",
context: {
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
});
}
management?.on("workspacechange", sendWorkspaceContext);
/**
* Handles plugin task requests received from the MCP server via WebSocket.
*
* @param request - The task request containing ID, task type and parameters
*/
async function handlePluginTaskRequest(request: { id: string; task: string; params: any }): Promise<void> {
console.log("Executing plugin task:", request.task, request.params);
const task = new Task(request.id, request.task, request.params);
// Find the appropriate handler
const handler = taskHandlers.find((h) => h.isApplicableTo(task));
if (handler) {
try {
// Cast the params to the expected type and handle the task
console.log("Processing task with handler:", handler);
await handler.handle(task);
// check whether a response was sent and send a generic success if not
if (!task.isResponseSent) {
console.warn("Handler did not send a response, sending generic success.");
task.sendSuccess("Task completed without a specific response.");
}
console.log("Task handled successfully:", task);
} catch (error) {
console.error("Error handling task:", error);
task.sendError(`Error handling task: ${formatTaskError(error)}`);
}
} else {
console.error("Unknown plugin task:", request.task);
task.sendError(`Unknown task type: ${request.task}`);
}
}
if (mcp) {
mcp.on("disconnect", async () => {
penpot.ui.sendMessage({
type: "stop-server",
});
});
mcp.on("connect", async () => {
if (!uiInitialized) return;
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
});
}
// Handle theme change in the iframe
penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({
source: "penpot",
type: "themechange",
theme,
});
});