mirror of
https://github.com/penpot/penpot.git
synced 2026-10-02 16:56:16 +00:00
* 🎉 Add multi-file session routing to the MCP server Session routing: - Track each user's Penpot connections by session ID. - Dispatch explicit sessions directly; otherwise discover the sole session. - Return a retryable error when discovery fails or is incomplete. Plugin and tools: - Initialize connections with a fresh session ID and file metadata. - Share the optional sessionId schema and place it last in tool inputs. Validation: - 84 tests, server type checking, and MCP formatting checks pass. - Disable test logging to avoid the logging worker shutdown hang. AI-assisted-by: gpt-6 * 🎉 Support independent MCP connections - Require explicit connection intent for each integrated workspace. - Derive short session IDs from the Penpot app instance and file. - Show and copy session IDs in the MCP menu and standalone plugin UI. - Stop stale callbacks and reconnect attempts after disconnect. Validate with frontend and plugin tests, type checking, live multi-tab checks, and standalone UI checks with a simulated connection. Server tests pass with --test-force-exit; the normal runner can hang on shutdown. AI-assisted-by: gpt-6 * 📚 Document multi-file MCP sessions - Explain independent connections, session selection, and copying IDs. - Describe session ID lifetimes for integrated and standalone plugins. - Correct browser focus, connection, and recovery guidance. Validate with the documentation site build and diff checks. AI-assisted-by: gpt-6 * ✨ Change behavior on session ID duplication * ✨ Open MCP toolbar menu on hover and match menu style --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> Co-authored-by: elhombretecla <delacruzgarciajuan@gmail.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { runInNewContext } from "node:vm";
|
|
import ts from "typescript";
|
|
import { shouldReconnectAfterClose } from "./ReconnectPolicy.ts";
|
|
import { SessionId } from "./SessionId.ts";
|
|
import { SessionIdDisplay } from "./SessionIdDisplay.ts";
|
|
|
|
/** Compiles the actual plugin entry point for a simulated browser boundary. */
|
|
function compile(name: string) {
|
|
return ts.transpileModule(readFileSync(new URL(name, import.meta.url), "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
}
|
|
|
|
function pluginUi() {
|
|
const messages: any[] = [];
|
|
const sockets: FakeSocket[] = [];
|
|
const events = new Map<string, (event?: any) => void>();
|
|
const timers = new Map<number, () => void>();
|
|
let timerId = 0;
|
|
class FakeSocket {
|
|
static OPEN = 1;
|
|
static CONNECTING = 0;
|
|
readyState = 0;
|
|
sent: any[] = [];
|
|
onopen?: () => void;
|
|
onclose?: (event: any) => void;
|
|
onmessage?: (event: any) => void;
|
|
constructor(_url: string) {
|
|
sockets.push(this);
|
|
}
|
|
open() {
|
|
this.readyState = 1;
|
|
this.onopen?.();
|
|
}
|
|
send(raw: string) {
|
|
this.sent.push(JSON.parse(raw));
|
|
}
|
|
close() {
|
|
this.readyState = 3;
|
|
this.onclose?.({ code: 1000 });
|
|
}
|
|
receive(message: any) {
|
|
this.onmessage?.({ data: JSON.stringify(message) });
|
|
}
|
|
}
|
|
const addEventListener = (name: string, handler: (event?: any) => void) => events.set(name, handler);
|
|
runInNewContext(compile("main.ts"), {
|
|
exports: {},
|
|
require: (name: string) => {
|
|
if (name === "./style.css") return {};
|
|
if (name === "./ReconnectPolicy") return { shouldReconnectAfterClose };
|
|
if (name === "./SessionId") return { SessionId };
|
|
if (name === "./SessionIdDisplay") return { SessionIdDisplay };
|
|
throw new Error(`Unexpected import: ${name}`);
|
|
},
|
|
console: { log() {}, error() {}, warn() {} },
|
|
crypto: { randomUUID },
|
|
URLSearchParams,
|
|
WebSocket: FakeSocket,
|
|
window: { location: { hash: "" }, addEventListener },
|
|
document: { body: { dataset: {} }, getElementById: () => null, addEventListener },
|
|
parent: { postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) },
|
|
setTimeout: (callback: () => void) => {
|
|
timers.set(++timerId, callback);
|
|
return timerId;
|
|
},
|
|
clearTimeout: (id: number) => timers.delete(id),
|
|
setInterval: () => ++timerId,
|
|
clearInterval: () => {},
|
|
PENPOT_MCP_WEBSOCKET_URL: "ws://localhost:4402",
|
|
});
|
|
const message = (data: any) => events.get("message")!({ data });
|
|
const connect = () => {
|
|
message({ type: "start-server" });
|
|
sockets.at(-1)!.open();
|
|
return messages.filter((message) => message.type === "connection-metadata-request").at(-1)!.sessionId;
|
|
};
|
|
return { messages, sockets, events, timers, message, connect };
|
|
}
|
|
|
|
test("sends file metadata before marking a plugin connection ready", async () => {
|
|
const ui = pluginUi();
|
|
const sessionId = ui.connect();
|
|
const initialization = {
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId, fileId: "file-1", fileName: "Design" },
|
|
};
|
|
await ui.message(initialization);
|
|
const registeredSession = ui.sockets[0].sent[0].session;
|
|
assert.equal(registeredSession.sessionId, "pq3gxqddgj");
|
|
assert.notEqual(registeredSession.sessionId, sessionId);
|
|
assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId });
|
|
assert.equal(
|
|
ui.messages.some((message) => message.status === "connected"),
|
|
false
|
|
);
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.equal(ui.messages.at(-1).status, "connected");
|
|
});
|
|
|
|
test("reconnects with the same session ID and ignores metadata for the old socket", async () => {
|
|
const ui = pluginUi();
|
|
const firstId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: firstId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
const sessionId = ui.sockets[0].sent[0].session.sessionId;
|
|
ui.sockets[0].close();
|
|
const reconnect = [...ui.timers.values()][0];
|
|
reconnect();
|
|
ui.sockets[1].open();
|
|
const secondId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId;
|
|
assert.notEqual(firstId, secondId);
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: firstId, fileId: "old", fileName: "Old file" },
|
|
});
|
|
assert.deepEqual(ui.sockets[1].sent, []);
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: secondId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId);
|
|
assert.notEqual(ui.sockets[1].sent[0].session.sessionId, secondId);
|
|
});
|
|
|
|
test("resume and freeze events cannot send liveness messages before initialization", () => {
|
|
const ui = pluginUi();
|
|
ui.connect();
|
|
ui.events.get("resume")!();
|
|
ui.events.get("freeze")!();
|
|
assert.deepEqual(ui.sockets[0].sent, []);
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
ui.events.get("resume")!();
|
|
assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
|
|
});
|
|
|
|
test("plugin reads the current file for each connection metadata request", () => {
|
|
let onMessage!: (message: any) => void;
|
|
const messages: any[] = [];
|
|
const penpot = {
|
|
currentFile: { id: "file-1", name: "First design" },
|
|
currentUser: { sessionId: "tab-1" },
|
|
theme: "dark",
|
|
ui: {
|
|
open() {},
|
|
onMessage: (handler: (message: any) => void) => {
|
|
onMessage = handler;
|
|
},
|
|
sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))),
|
|
},
|
|
on() {},
|
|
};
|
|
runInNewContext(compile("plugin.ts"), {
|
|
exports: {},
|
|
penpot,
|
|
mcp: undefined,
|
|
require: () => ({ ExecuteCodeTaskHandler: class {} }),
|
|
});
|
|
onMessage({ type: "connection-metadata-request", sessionId: "first" });
|
|
penpot.currentFile = { id: "file-2", name: "Second design" };
|
|
onMessage({ type: "connection-metadata-request", sessionId: "second" });
|
|
assert.deepEqual(messages, [
|
|
{
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: "first", fileId: "file-1", fileName: "First design" },
|
|
},
|
|
{
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: "second", fileId: "file-2", fileName: "Second design" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("integrated connections reuse the short ID across reconnects", async () => {
|
|
const ui = pluginUi();
|
|
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });
|
|
const initialize = async (requestId: string) => {
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: requestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
};
|
|
await initialize(ui.connect());
|
|
assert.equal(ui.sockets[0].sent[0].session.sessionId, "pq3gxqddgj");
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.equal(ui.messages.at(-1).sessionId, "pq3gxqddgj");
|
|
ui.sockets[0].close();
|
|
[...ui.timers.values()][0]();
|
|
ui.sockets[1].open();
|
|
const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId;
|
|
await initialize(requestId);
|
|
assert.equal(ui.sockets[1].sent[0].session.sessionId, "pq3gxqddgj");
|
|
});
|
|
|
|
test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => {
|
|
const ui = pluginUi();
|
|
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });
|
|
const requestId = ui.connect();
|
|
const pending = ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: requestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
ui.message({ type: "stop-server" });
|
|
await pending;
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.deepEqual(ui.sockets[0].sent, []);
|
|
assert.equal(ui.messages.at(-1).status, "disconnected");
|
|
assert.equal(ui.timers.size, 0);
|
|
});
|
|
|
|
test("integrated plugin stays idle until explicitly connected, including while the UI loads", () => {
|
|
let onMessage!: (message: any) => void;
|
|
const events = new Map<string, () => void>();
|
|
const messages: any[] = [];
|
|
const statuses: string[] = [];
|
|
runInNewContext(compile("plugin.ts"), {
|
|
exports: {},
|
|
console: { log() {} },
|
|
PENPOT_MCP_VERSION: "0.0.0",
|
|
penpot: {
|
|
theme: "dark",
|
|
version: "0.0.0",
|
|
ui: {
|
|
open() {},
|
|
onMessage: (handler: (message: any) => void) => {
|
|
onMessage = handler;
|
|
},
|
|
sendMessage: (message: any) => messages.push(message),
|
|
},
|
|
on() {},
|
|
},
|
|
mcp: {
|
|
getToken: () => "test-token",
|
|
getServerUrl: () => "ws://localhost:4402",
|
|
isConnectionRequested: () => false,
|
|
setMcpStatus: (status: string) => statuses.push(status),
|
|
on: (event: string, callback: () => void) => events.set(event, callback),
|
|
},
|
|
require: () => ({ ExecuteCodeTaskHandler: class {} }),
|
|
});
|
|
assert.ok(!statuses.includes("connecting"));
|
|
events.get("connect")!();
|
|
events.get("disconnect")!();
|
|
onMessage({ type: "ui-initialized" });
|
|
assert.ok(!messages.some((message) => message.type === "start-server"));
|
|
events.get("connect")!();
|
|
assert.equal(messages.at(-1).type, "start-server");
|
|
});
|
|
|
|
test("standalone session ID survives reconnects and plugin restarts in the same app instance", async () => {
|
|
const ui = pluginUi();
|
|
const firstRequestId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: firstRequestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
const sessionId = ui.sockets[0].sent[0].session.sessionId;
|
|
ui.message({ type: "stop-server" });
|
|
assert.equal(ui.messages.at(-1).sessionId, null);
|
|
const secondRequestId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: secondRequestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId);
|
|
ui.sockets[1].receive({ type: "initialized" });
|
|
assert.equal(ui.messages.at(-1).sessionId, sessionId);
|
|
|
|
const next = pluginUi();
|
|
const nextRequestId = next.connect();
|
|
await next.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: nextRequestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId);
|
|
});
|