penpot/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.test.ts
Dr. Dominik Jain 8efbd9b5d3
🎉 Allow MCP to work with multiple files in parallel (#11927)
* 🎉 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>
2026-10-02 14:22:52 +02:00

372 lines
16 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import type { PenpotSession, PluginTaskRequest, PluginTaskResponse } from "@penpot/mcp-common";
import type { PenpotConnection } from "../PenpotConnection";
import { PluginTask, type AbstractPluginTask } from "../PluginTask";
import type { RedisBridge, SessionClaim, SessionDiscoveryRequest, SessionDiscoveryResponse } from "../RedisBridge";
import { UserPenpotConnections } from "../UserPenpotConnections";
import { MultiInstanceTaskDispatcher } from "./MultiInstanceTaskDispatcher";
import { SessionDiscoveryError, type TaskDispatchHost } from "./TaskDispatcher";
/** In-memory Pub/Sub transport retaining Redis's per-subscriber delivery count. */
class RedisNetwork {
readonly tasks = new Map<string, Map<RedisPeer, (request: PluginTaskRequest) => void>>();
readonly discovery = new Map<string, Map<RedisPeer, (request: SessionDiscoveryRequest) => void>>();
readonly taskResponses = new Map<string, (response: PluginTaskResponse<unknown>) => void>();
readonly discoveryResponses = new Map<string, (response: SessionDiscoveryResponse) => void>();
readonly claims = new Map<string, Map<RedisPeer, (claim: SessionClaim) => void>>();
failDiscovery = false;
dropClaims = false;
beforeDiscoveryPublish?: () => Promise<void>;
beforeClaimPublish?: () => Promise<void>;
}
/** One MCP instance's connection to the test Pub/Sub network. */
class RedisPeer {
replyToDiscovery = true;
repeatDiscoveryReplies = false;
constructor(readonly network: RedisNetwork) {}
asBridge(): RedisBridge {
return this as unknown as RedisBridge;
}
async subscribeToTasks(token: string, session: string, handler: (request: PluginTaskRequest) => void) {
const key = JSON.stringify([token, session]);
const subscribers = this.network.tasks.get(key) ?? new Map();
subscribers.set(this, handler);
this.network.tasks.set(key, subscribers);
}
async unsubscribeFromTasks(token: string, session: string) {
this.network.tasks.get(JSON.stringify([token, session]))?.delete(this);
}
async sendTaskRequest(
token: string,
session: string,
request: PluginTaskRequest,
onResponse: (response: PluginTaskResponse<unknown>) => void
) {
this.network.taskResponses.set(request.id, onResponse);
const subscribers = this.network.tasks.get(JSON.stringify([token, session])) ?? new Map();
for (const handler of subscribers.values()) handler(request);
if (!subscribers.size) this.network.taskResponses.delete(request.id);
return subscribers.size;
}
publishTaskResponse(id: string, response: PluginTaskResponse<unknown>) {
const handler = this.network.taskResponses.get(id);
this.network.taskResponses.delete(id);
handler?.(response);
}
async subscribeToDiscovery(token: string, handler: (request: SessionDiscoveryRequest) => void) {
const subscribers = this.network.discovery.get(token) ?? new Map();
subscribers.set(this, handler);
this.network.discovery.set(token, subscribers);
}
async unsubscribeFromDiscovery(token: string) {
this.network.discovery.get(token)?.delete(this);
}
async sendDiscoveryRequest(token: string, id: string, onResponse: (response: SessionDiscoveryResponse) => void) {
if (this.network.failDiscovery) throw new Error("Redis unavailable for discovery");
this.network.discoveryResponses.set(id, onResponse);
await this.network.beforeDiscoveryPublish?.();
const subscribers = this.network.discovery.get(token) ?? new Map();
for (const [peer, handler] of subscribers) {
if (peer.replyToDiscovery) {
handler({ id });
if (peer.repeatDiscoveryReplies) handler({ id });
}
}
return subscribers.size;
}
publishDiscoveryResponse(id: string, response: SessionDiscoveryResponse) {
this.network.discoveryResponses.get(id)?.(response);
}
async unsubscribeFromDiscoveryResponses(id: string) {
this.network.discoveryResponses.delete(id);
}
async subscribeToSessionClaims(token: string, handler: (claim: SessionClaim) => void) {
const subscribers = this.network.claims.get(token) ?? new Map();
subscribers.set(this, handler);
this.network.claims.set(token, subscribers);
}
async unsubscribeFromSessionClaims(token: string) {
this.network.claims.get(token)?.delete(this);
}
async publishSessionClaim(token: string, claim: SessionClaim) {
await this.network.beforeClaimPublish?.();
if (this.network.dropClaims) return;
for (const handler of [...(this.network.claims.get(token)?.values() ?? [])]) handler(claim);
}
}
/** Local registry and execution endpoint used with the real dispatchers. */
class Instance implements TaskDispatchHost {
readonly users = new Map<string, UserPenpotConnections>();
readonly redis: RedisPeer;
readonly dispatcher: MultiInstanceTaskDispatcher;
readonly executions: string[] = [];
readonly displaced: string[] = [];
constructor(network: RedisNetwork) {
this.redis = new RedisPeer(network);
this.dispatcher = new MultiInstanceTaskDispatcher(this, this.redis.asBridge(), 20);
}
async connect(token: string, sessionId: string, fileName = "Design") {
const session: PenpotSession = { sessionId, fileId: "same-file", fileName };
const connection = { userToken: token, session, ready: false, socket: { readyState: 1 } } as PenpotConnection;
const connections = this.users.get(token) ?? new UserPenpotConnections();
this.users.set(token, connections);
connections.add(connection);
await this.dispatcher.onNewConnection(connection);
connection.ready = true;
return connection;
}
async disconnect(connection: PenpotConnection) {
this.users.get(connection.userToken!)!.remove(connection);
connection.ready = false;
await this.dispatcher.onConnectionClosed(connection);
}
displaceConnection(connection: PenpotConnection) {
this.displaced.push(connection.session.sessionId);
void this.disconnect(connection);
}
getUserConnections(token: string | null) {
return this.users.get(token!);
}
sendLocalTask(task: AbstractPluginTask, connection: PenpotConnection) {
if (!connection.ready) throw new Error("Plugin disconnected");
this.executions.push(connection.session.sessionId);
task.resolveWithResult({ data: connection.session.sessionId });
}
sendRemoteTask(task: AbstractPluginTask, token: string, sessionId: string) {
void this.redis
.sendTaskRequest(token, sessionId, task.toRequest(), (response) => {
if (response.success) task.resolveWithResult({ data: response.data });
else task.rejectWithError(new Error(response.error));
})
.then((count) => {
if (!count) task.rejectWithError(new Error("Session not connected"));
});
}
async execute(token: string, sessionId?: string) {
const task = new PluginTask("test", {});
await this.dispatcher.dispatch(task, token, sessionId);
return await task.getResultPromise();
}
}
test("discovers all sessions across instances, including several tabs for one file", async () => {
const network = new RedisNetwork();
const first = new Instance(network);
const second = new Instance(network);
await first.connect("alice", "one");
await first.connect("alice", "two");
await second.connect("alice", "three");
await second.connect("bob", "private");
const sessions = await new Instance(network).dispatcher.discoverSessions("alice");
assert.deepEqual(sessions.map((session) => session.sessionId).sort(), ["one", "three", "two"]);
assert.equal(network.discoveryResponses.size, 0);
});
test("explicit sessions dispatch remotely without discovery", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
const requester = new Instance(network);
await owner.connect("alice", "selected");
network.failDiscovery = true;
assert.equal((await requester.execute("alice", "selected")).data, "selected");
});
test("explicit sessions work through Redis on their owning instance too", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
await owner.connect("alice", "selected");
network.failDiscovery = true;
assert.equal((await owner.execute("alice", "selected")).data, "selected");
});
test("explicit session IDs cannot route tasks across user boundaries", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
await owner.connect("bob", "private");
await assert.rejects(new Instance(network).execute("alice", "private"), /not connected/);
assert.deepEqual(owner.executions, []);
});
test("complete discovery dispatches when exactly one session exists", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
await owner.connect("alice", "only");
assert.equal((await new Instance(network).execute("alice")).data, "only");
});
test("complete discovery requires selection when more than one session exists", async () => {
const network = new RedisNetwork();
await new Instance(network).connect("alice", "first", "First file");
await new Instance(network).connect("alice", "second", "Second file");
await assert.rejects(new Instance(network).execute("alice"), (error: Error) => {
assert.match(error.message, /Ask the user/);
assert.match(error.message, /first.*First file/);
assert.match(error.message, /second.*Second file/);
return true;
});
});
test("zero discovery subscribers reports no connected sessions", async () => {
await assert.rejects(new Instance(new RedisNetwork()).execute("alice"), /No Penpot sessions/);
});
test("incomplete discovery never dispatches even when it yields exactly one session", async () => {
const network = new RedisNetwork();
const responsive = new Instance(network);
const slow = new Instance(network);
await responsive.connect("alice", "visible");
await slow.connect("alice", "hidden");
slow.redis.replyToDiscovery = false;
await assert.rejects(new Instance(network).execute("alice"), (error: Error) => {
assert.ok(error instanceof SessionDiscoveryError);
assert.match(error.message, /temporary server load.*retry/);
return true;
});
assert.deepEqual(responsive.executions, []);
assert.equal(network.discoveryResponses.size, 0);
});
test("duplicate replies from one instance do not substitute for a missing reply", async () => {
const network = new RedisNetwork();
const responsive = new Instance(network);
const slow = new Instance(network);
await responsive.connect("alice", "visible");
await slow.connect("alice", "hidden");
responsive.redis.repeatDiscoveryReplies = true;
slow.redis.replyToDiscovery = false;
await assert.rejects(new Instance(network).execute("alice"), SessionDiscoveryError);
});
test("discovery transport failures use the retryable discovery error", async () => {
const network = new RedisNetwork();
network.failDiscovery = true;
await assert.rejects(new Instance(network).execute("alice"), SessionDiscoveryError);
});
test("disconnecting one tab retains discovery and dispatch for the remaining tab", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
const first = await owner.connect("alice", "first");
await owner.connect("alice", "second");
await owner.disconnect(first);
assert.equal((await new Instance(network).execute("alice")).data, "second");
});
test("disconnecting the last tab removes its discovery subscription", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
const connection = await owner.connect("alice", "only");
await owner.disconnect(connection);
assert.equal(network.discovery.get("alice")?.size, 0);
await assert.rejects(new Instance(network).execute("alice"), /No Penpot sessions/);
});
test("concurrent discoveries keep response channels and users separate", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
const requester = new Instance(network);
await owner.connect("alice", "alice-tab");
await owner.connect("bob", "bob-tab");
const results = await Promise.all([requester.execute("alice"), requester.execute("bob")]);
assert.deepEqual(
results.map((result) => result.data),
["alice-tab", "bob-tab"]
);
assert.equal(network.discoveryResponses.size, 0);
});
test("a publish completing after timeout cannot dispatch or retain its response subscription", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
await owner.connect("alice", "late");
let release!: () => void;
network.beforeDiscoveryPublish = () =>
new Promise<void>((resolve) => {
release = resolve;
});
await assert.rejects(new Instance(network).execute("alice"), SessionDiscoveryError);
release();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(owner.executions, []);
assert.equal(network.discoveryResponses.size, 0);
});
test("a newer connection for a session on another instance displaces the older one", async () => {
const network = new RedisNetwork();
const previous = new Instance(network);
const current = new Instance(network);
await previous.connect("alice", "tab");
await current.connect("alice", "tab");
assert.deepEqual(previous.displaced, ["tab"]);
assert.deepEqual(current.displaced, []);
assert.equal((await new Instance(network).execute("alice")).data, "tab");
assert.deepEqual(previous.executions, []);
assert.deepEqual(current.executions, ["tab"]);
});
test("a claim published before a local connection's own claim does not displace it", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
const other = new Instance(network);
await owner.connect("alice", "sibling");
let release!: () => void;
network.beforeClaimPublish = () =>
new Promise<void>((resolve) => {
release = resolve;
});
const pending = owner.connect("alice", "tab");
await new Promise<void>((resolve) => setImmediate(resolve));
network.beforeClaimPublish = undefined;
// the other instance's claim is delivered first, so the owner's connection is the newer one
await other.connect("alice", "tab");
release();
await pending;
assert.deepEqual(owner.displaced, []);
assert.deepEqual(other.displaced, ["tab"]);
});
test("discovery lists a session reported by several instances once", async () => {
const network = new RedisNetwork();
network.dropClaims = true;
await new Instance(network).connect("alice", "tab");
await new Instance(network).connect("alice", "tab");
const sessions = await new Instance(network).dispatcher.discoverSessions("alice");
assert.deepEqual(
sessions.map((session) => session.sessionId),
["tab"]
);
});
test("disconnecting the last tab removes its session claim subscription", async () => {
const network = new RedisNetwork();
const owner = new Instance(network);
const connection = await owner.connect("alice", "only");
await owner.disconnect(connection);
assert.equal(network.claims.get("alice")?.size, 0);
});