mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +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>
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { once } from "node:events";
|
|
import { beforeEach, test, type TestContext } from "node:test";
|
|
import Redis, { type Command } from "ioredis";
|
|
import { WebSocket } from "ws";
|
|
import { RedisBridge } from "./RedisBridge";
|
|
import { PluginBridge } from "./PluginBridge";
|
|
import { PluginTask } from "./PluginTask";
|
|
import type { PenpotMcpServer } from "./PenpotMcpServer";
|
|
|
|
// Replace only the Redis command boundary; exercise the real transport,
|
|
// dispatchers, task tracking, and WebSockets above it.
|
|
let subscriptions: Map<string, Set<Redis>>;
|
|
let failDiscoverySubscription: boolean;
|
|
beforeEach((context) => {
|
|
// These hooks run for tests, so mocks live through each test's cleanup hooks.
|
|
const { mock } = context as TestContext;
|
|
subscriptions = new Map();
|
|
failDiscoverySubscription = false;
|
|
mock.method(Redis.prototype, "connect", async function (this: Redis) {
|
|
this.status = "ready";
|
|
});
|
|
mock.method(Redis.prototype, "sendCommand", async function (this: Redis, command: Command) {
|
|
const [channel, message] = command.args.map(String);
|
|
if (command.name === "subscribe") {
|
|
const subscribers = subscriptions.get(channel) ?? new Set();
|
|
subscribers.add(this);
|
|
subscriptions.set(channel, subscribers);
|
|
if (failDiscoverySubscription && channel.includes(".discovery.req.")) {
|
|
failDiscoverySubscription = false;
|
|
throw new Error("Subscription failed");
|
|
}
|
|
return subscribers.size;
|
|
}
|
|
if (command.name === "unsubscribe") {
|
|
subscriptions.get(channel)?.delete(this);
|
|
return 0;
|
|
}
|
|
if (command.name === "publish") {
|
|
const subscribers = [...(subscriptions.get(channel) ?? [])];
|
|
for (const subscriber of subscribers) subscriber.emit("message", channel, message);
|
|
return subscribers.length;
|
|
}
|
|
if (command.name === "quit") {
|
|
for (const subscribers of subscriptions.values()) subscribers.delete(this);
|
|
return "OK";
|
|
}
|
|
throw new Error(`Unexpected Redis command: ${command.name}`);
|
|
});
|
|
});
|
|
|
|
function transport(t: TestContext, tenant = "test") {
|
|
const bridge = new RedisBridge("redis://localhost:6379", tenant);
|
|
t.after(() => bridge.close());
|
|
return bridge;
|
|
}
|
|
|
|
test("Redis task channels isolate users, sessions, and tenants without delimiter collisions", async (t) => {
|
|
const owner = transport(t);
|
|
const caller = transport(t);
|
|
const otherTenant = transport(t, "other");
|
|
const delivered: unknown[] = [];
|
|
await owner.subscribeToTasks("alice.one", "two", (request) => {
|
|
owner.publishTaskResponse(request.id, { id: request.id, success: true, data: "selected" });
|
|
});
|
|
for (const [token, session] of [
|
|
["alice", "one.two"],
|
|
["bob", "two"],
|
|
["alice.one", "other"],
|
|
]) {
|
|
assert.equal(
|
|
await caller.sendTaskRequest(token, session, { id: "missing", task: "test", params: {} }, () => {}),
|
|
0
|
|
);
|
|
}
|
|
assert.equal(
|
|
await otherTenant.sendTaskRequest(
|
|
"alice.one",
|
|
"two",
|
|
{ id: "other-tenant", task: "test", params: {} },
|
|
() => {}
|
|
),
|
|
0
|
|
);
|
|
assert.equal(
|
|
await caller.sendTaskRequest("alice.one", "two", { id: "selected", task: "test", params: {} }, (response) =>
|
|
delivered.push(response.data)
|
|
),
|
|
1
|
|
);
|
|
assert.deepEqual(delivered, ["selected"]);
|
|
});
|
|
|
|
test("Redis discovery receives one reply per subscribed instance and returns publish's recipient count", async (t) => {
|
|
const first = transport(t);
|
|
const second = transport(t);
|
|
const requester = transport(t);
|
|
await first.subscribeToDiscovery("alice", ({ id }) => {
|
|
first.publishDiscoveryResponse(id, {
|
|
instanceId: "first",
|
|
sessions: [{ sessionId: "one", fileId: "file", fileName: "Design" }],
|
|
});
|
|
});
|
|
await second.subscribeToDiscovery("alice", ({ id }) => {
|
|
second.publishDiscoveryResponse(id, { instanceId: "second", sessions: [] });
|
|
});
|
|
const responders: string[] = [];
|
|
const count = await requester.sendDiscoveryRequest("alice", "query", (response) =>
|
|
responders.push(response.instanceId)
|
|
);
|
|
assert.equal(count, 2);
|
|
assert.deepEqual(responders.sort(), ["first", "second"]);
|
|
await requester.unsubscribeFromDiscoveryResponses("query");
|
|
first.publishDiscoveryResponse("query", { instanceId: "late", sessions: [] });
|
|
assert.deepEqual(responders.sort(), ["first", "second"]);
|
|
});
|
|
|
|
test("unsubscribing a session retains the user's other task channels", async (t) => {
|
|
const owner = transport(t);
|
|
const caller = transport(t);
|
|
await owner.subscribeToTasks("alice", "first", () => {});
|
|
await owner.subscribeToTasks("alice", "second", () => {});
|
|
await owner.unsubscribeFromTasks("alice", "first");
|
|
assert.equal(
|
|
await caller.sendTaskRequest("alice", "first", { id: "first", task: "test", params: {} }, () => {}),
|
|
0
|
|
);
|
|
assert.equal(
|
|
await caller.sendTaskRequest("alice", "second", { id: "second", task: "test", params: {} }, () => {}),
|
|
1
|
|
);
|
|
await caller.unsubscribeFromResponse("second");
|
|
});
|
|
|
|
test("a failed discovery subscription is cleaned up and can be retried", async (t) => {
|
|
const owner = transport(t);
|
|
const caller = transport(t);
|
|
failDiscoverySubscription = true;
|
|
await assert.rejects(
|
|
owner.subscribeToDiscovery("alice", () => {}),
|
|
/Subscription failed/
|
|
);
|
|
assert.equal(await caller.sendDiscoveryRequest("alice", "after-failure", () => {}), 0);
|
|
await caller.unsubscribeFromDiscoveryResponses("after-failure");
|
|
await owner.subscribeToDiscovery("alice", ({ id }) => {
|
|
owner.publishDiscoveryResponse(id, { instanceId: "owner", sessions: [] });
|
|
});
|
|
assert.equal(await caller.sendDiscoveryRequest("alice", "retry", () => {}), 1);
|
|
await caller.unsubscribeFromDiscoveryResponses("retry");
|
|
});
|
|
|
|
let port = 18_700;
|
|
function instance(t: TestContext) {
|
|
const redis = new RedisBridge("redis://localhost:6379", "test");
|
|
const wsPort = ++port;
|
|
const bridge = new PluginBridge(
|
|
{
|
|
host: "127.0.0.1",
|
|
isMultiUserMode: () => true,
|
|
getSessionContext: () => ({ userToken: "alice" }),
|
|
} as PenpotMcpServer,
|
|
wsPort,
|
|
0.1,
|
|
redis
|
|
);
|
|
const sockets: WebSocket[] = [];
|
|
t.after(async () => {
|
|
for (const socket of sockets) socket.terminate();
|
|
await bridge.close();
|
|
await redis.close();
|
|
});
|
|
const connect = async (
|
|
sessionId: string,
|
|
outcome: "success" | "error" | "silent" = "success",
|
|
reply: string = sessionId
|
|
) => {
|
|
const socket = new WebSocket(`ws://127.0.0.1:${wsPort}?userToken=alice`);
|
|
sockets.push(socket);
|
|
await once(socket, "open");
|
|
const initialized = once(socket, "message", { signal: AbortSignal.timeout(2000) });
|
|
socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file", fileName: "Design" } }));
|
|
await initialized;
|
|
socket.on("message", (raw) => {
|
|
const request = JSON.parse(raw.toString());
|
|
if (outcome !== "silent") {
|
|
socket.send(
|
|
JSON.stringify({
|
|
id: request.id,
|
|
success: outcome === "success",
|
|
data: reply,
|
|
error: "Plugin execution failed",
|
|
})
|
|
);
|
|
}
|
|
});
|
|
return socket;
|
|
};
|
|
return { bridge, connect };
|
|
}
|
|
|
|
test("full Redis forwarding resolves the selected WebSocket's result", async (t) => {
|
|
const owner = instance(t);
|
|
const requester = instance(t);
|
|
await owner.connect("first");
|
|
await owner.connect("second");
|
|
assert.equal((await requester.bridge.executePluginTask(new PluginTask("test", {}), "second")).data, "second");
|
|
assert.equal((await owner.bridge.executePluginTask(new PluginTask("test", {}), "first")).data, "first");
|
|
});
|
|
|
|
test("full Redis discovery implicitly selects a sole remote WebSocket", async (t) => {
|
|
const owner = instance(t);
|
|
const requester = instance(t);
|
|
await owner.connect("only");
|
|
assert.equal((await requester.bridge.executePluginTask(new PluginTask("test", {}))).data, "only");
|
|
});
|
|
|
|
test("forwarded plugin failures retain the regular execution failure route", async (t) => {
|
|
const owner = instance(t);
|
|
const requester = instance(t);
|
|
await owner.connect("failing", "error");
|
|
await assert.rejects(
|
|
requester.bridge.executePluginTask(new PluginTask("test", {}), "failing"),
|
|
/Plugin execution failed/
|
|
);
|
|
});
|
|
|
|
test("forwarded task timeouts release their Redis response subscriptions", async (t) => {
|
|
const owner = instance(t);
|
|
const requester = instance(t);
|
|
await owner.connect("silent", "silent");
|
|
await assert.rejects(requester.bridge.executePluginTask(new PluginTask("test", {}), "silent"), /timed out/);
|
|
const responseSubscriptions = [...subscriptions].filter(([channel]) => channel.includes(".task.res."));
|
|
assert.ok(responseSubscriptions.every(([, subscribers]) => subscribers.size === 0));
|
|
});
|
|
|
|
test("a reconnect on another instance closes the previous instance's connection for the session", async (t) => {
|
|
const previous = instance(t);
|
|
const current = instance(t);
|
|
const requester = instance(t);
|
|
const previousSocket = await previous.connect("tab", "success", "previous");
|
|
const closed = once(previousSocket, "close");
|
|
await current.connect("tab", "success", "current");
|
|
assert.equal((await closed)[0], 1008);
|
|
assert.equal((await requester.bridge.executePluginTask(new PluginTask("test", {}))).data, "current");
|
|
});
|