penpot/mcp/packages/server/src/RedisBridge.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

352 lines
14 KiB
TypeScript

import Redis from "ioredis";
import { PluginTaskRequest, PluginTaskResponse, PenpotSession } from "@penpot/mcp-common";
import { createLogger } from "./logger";
import { z } from "zod";
/** Query for the user's sessions held by each subscribed MCP instance. */
export interface SessionDiscoveryRequest {
id: string;
}
/** One MCP instance's current sessions for the queried user. */
export interface SessionDiscoveryResponse {
instanceId: string;
sessions: PenpotSession[];
}
/** Announcement that an MCP instance holds a new connection for a Penpot session. */
export interface SessionClaim {
instanceId: string;
/** identifies the claiming connection, so an instance can recognize its own claims. */
claimId: string;
sessionId: string;
}
const sessionClaimSchema = z.object({
instanceId: z.string().min(1),
claimId: z.string().min(1),
sessionId: z.string().min(1).max(128),
});
const discoveryResponseSchema = z.object({
instanceId: z.string().min(1),
sessions: z.array(
z.object({
sessionId: z.string().min(1).max(128),
fileId: z.string().min(1).max(128),
fileName: z.string(),
})
),
});
/**
* Handler invoked for a task request arriving on a subscribed request channel.
*/
export type TaskRequestHandler = (request: PluginTaskRequest) => void;
/**
* Handler invoked for a task response arriving on a subscribed response channel.
*/
export type TaskResponseHandler = (response: PluginTaskResponse<any>) => void;
/**
* Provides a Redis-backed transport for routing plugin task requests and responses
* between MCP server instances.
*
* The bridge is a pure, stateless transport: it moves already-serialized
* `PluginTaskRequest` and `PluginTaskResponse` objects between instances and does not
* interpret their contents, correlate requests with responses, or impose timeouts.
* Correlation and timeout handling remain the responsibility of the caller (see
* `PluginBridge`, which routes Redis-delivered responses through the same
* pending-task machinery used for direct WebSocket dispatch).
*
* It enables a tool call handled on one instance to be executed against a plugin
* whose WebSocket connection lives on another instance: the request is published on a
* channel keyed by user token and session ID (to which the instance holding the plugin connection is
* subscribed), and the response is published on a channel keyed by task ID (to which
* the issuing instance subscribes).
*
* Two Redis connections are used, as ioredis requires a dedicated connection while
* subscribed: one for commands and publishing, and one for subscriptions.
*/
export class RedisBridge {
private readonly logger = createLogger("RedisBridge");
private readonly publisher: Redis;
private readonly subscriber: Redis;
private readonly tenant: string;
/**
* Message handlers keyed by channel name.
*
* ioredis exposes a single, global message event for all subscribed channels, so
* incoming messages are dispatched to the correct handler by channel name. Both
* request-channel and response-channel handlers are stored here.
*/
private readonly handlers = new Map<string, (rawMessage: string) => void>();
/**
* Creates a Redis bridge connected to the given Redis instance.
*
* @param redisUri - The Redis connection URI (e.g. `redis://host:6379`)
* @param tenant - The tenant identifier, used to qualify Redis channel names so that
* multiple environments sharing a Redis instance do not interfere.
*/
constructor(redisUri: string, tenant: string) {
this.tenant = tenant;
this.publisher = new Redis(redisUri);
this.subscriber = new Redis(redisUri);
this.subscriber.on("message", (channel: string, rawMessage: string) => {
const handler = this.handlers.get(channel);
if (handler) {
handler(rawMessage);
} else {
this.logger.debug("Received Redis message without a registered handler");
}
});
}
/** Builds a task channel scoped to both the user and the Penpot session. */
private requestChannel(userToken: string, sessionId: string): string {
return `penpot.mcp.${this.tenant}.task.req.${this.channelKey(userToken)}.${this.channelKey(sessionId)}`;
}
/** Encodes identifiers without ambiguous channel-name separators. */
private channelKey(value: string): string {
return Buffer.from(value).toString("base64url");
}
/** Builds the Redis Pub/Sub channel name for a task response keyed by task ID. */
private responseChannel(taskId: string): string {
return `penpot.mcp.${this.tenant}.task.res.${taskId}`;
}
/**
* Subscribes to the response channel for the given task ID and publishes the task
* request to the given user's session channel.
*
* The response subscription is established *before* the request is published, to
* avoid a race in which the response would be published before the subscription is
* in place. The response handler is invoked at most once and the subscription is
* removed automatically upon delivery (response channels are single-use).
*
* @param userToken - The user token identifying the target plugin's request channel
* @param sessionId - The Penpot session identifying the target connection
* @param request - The serialized plugin task request, passed through verbatim
* @param onResponse - Handler invoked with the response when it arrives
* @returns The number of instances that received the request. A count of 0 means no
* instance is subscribed to the session's request channel (i.e. the plugin is not
* connected anywhere); the request was dropped, no response will ever arrive, and
* the response subscription has already been released.
*/
async sendTaskRequest(
userToken: string,
sessionId: string,
request: PluginTaskRequest,
onResponse: TaskResponseHandler
): Promise<number> {
const responseChannel = this.responseChannel(request.id);
const requestChannel = this.requestChannel(userToken, sessionId);
this.handlers.set(responseChannel, (rawMessage) => {
// a response channel is single-use: remove the handler and unsubscribe on delivery
this.handlers.delete(responseChannel);
void this.subscriber.unsubscribe(responseChannel);
try {
onResponse(JSON.parse(rawMessage) as PluginTaskResponse<any>);
} catch (error) {
this.logger.error(error, "Failed to parse task response message");
}
});
await this.subscriber.subscribe(responseChannel);
// publish only once the response subscription is confirmed
let receiverCount: number;
try {
receiverCount = await this.publisher.publish(requestChannel, JSON.stringify(request));
} catch (error) {
// the request was never delivered, so no response can arrive
await this.unsubscribeFromResponse(request.id);
throw error;
}
if (receiverCount === 0) {
// no subscriber received the request, so no response can arrive
await this.unsubscribeFromResponse(request.id);
}
return receiverCount;
}
/**
* Unsubscribes from the response channel for the given task ID.
*
* Used to release a response subscription when no response will be processed (e.g.
* the awaiting task has timed out), since in that case the self-unsubscribe on
* delivery never occurs.
*
* @param taskId - The task ID whose response channel to unsubscribe from
*/
async unsubscribeFromResponse(taskId: string): Promise<void> {
const responseChannel = this.responseChannel(taskId);
this.handlers.delete(responseChannel);
await this.subscriber.unsubscribe(responseChannel);
}
/**
* Publishes a task response on the response channel for the given task ID.
*
* Used by the instance executing a forwarded task to return its outcome to the
* issuing instance.
*
* @param taskId - The ID of the originally requested task
* @param response - The serialized plugin task response, passed through verbatim
*/
publishTaskResponse(taskId: string, response: PluginTaskResponse<any>): void {
const responseChannel = this.responseChannel(taskId);
void this.publisher
.publish(responseChannel, JSON.stringify(response))
.catch((error) => this.logger.error(error, "Failed to publish task response"));
}
/**
* Subscribes to task requests for the given user's Penpot session.
*
* The handler is invoked for each request arriving on the session's request channel.
*
* @param userToken - The user token whose request channel to subscribe to
* @param sessionId - The Penpot session whose task channel to subscribe to
* @param handler - The handler to invoke for incoming requests
*/
async subscribeToTasks(userToken: string, sessionId: string, handler: TaskRequestHandler): Promise<void> {
const requestChannel = this.requestChannel(userToken, sessionId);
this.handlers.set(requestChannel, (rawMessage) => {
try {
handler(JSON.parse(rawMessage) as PluginTaskRequest);
} catch (error) {
this.logger.error(error, "Failed to parse task request message");
}
});
await this.subscriber.subscribe(requestChannel);
}
/**
* Unsubscribes from task requests for the given user's Penpot session.
*
* @param userToken - The user token whose request channel to unsubscribe from
* @param sessionId - The Penpot session whose task channel to unsubscribe from
*/
async unsubscribeFromTasks(userToken: string, sessionId: string): Promise<void> {
const requestChannel = this.requestChannel(userToken, sessionId);
this.handlers.delete(requestChannel);
await this.subscriber.unsubscribe(requestChannel);
}
private discoveryChannel(userToken: string): string {
return `penpot.mcp.${this.tenant}.discovery.req.${this.channelKey(userToken)}`;
}
private discoveryResponseChannel(requestId: string): string {
return `penpot.mcp.${this.tenant}.discovery.res.${this.channelKey(requestId)}`;
}
/** Subscribes to replies before publishing; returns the actual recipient count. */
async sendDiscoveryRequest(
userToken: string,
requestId: string,
onResponse: (response: SessionDiscoveryResponse) => void
): Promise<number> {
const channel = this.discoveryResponseChannel(requestId);
this.handlers.set(channel, (raw) => {
try {
onResponse(discoveryResponseSchema.parse(JSON.parse(raw)));
} catch (error) {
this.logger.error(error, "Invalid discovery response");
}
});
try {
await this.subscriber.subscribe(channel);
return await this.publisher.publish(this.discoveryChannel(userToken), JSON.stringify({ id: requestId }));
} catch (error) {
await this.unsubscribeFromDiscoveryResponses(requestId);
throw error;
}
}
async unsubscribeFromDiscoveryResponses(requestId: string): Promise<void> {
const channel = this.discoveryResponseChannel(requestId);
this.handlers.delete(channel);
await this.subscriber.unsubscribe(channel);
}
/** Registers one discovery responder for this user's local connections. */
async subscribeToDiscovery(userToken: string, handler: (request: SessionDiscoveryRequest) => void): Promise<void> {
const channel = this.discoveryChannel(userToken);
this.handlers.set(channel, (raw) => {
try {
handler(z.object({ id: z.string().min(1) }).parse(JSON.parse(raw)));
} catch (error) {
this.logger.error(error, "Invalid discovery request");
}
});
try {
await this.subscriber.subscribe(channel);
} catch (error) {
await this.unsubscribeFromDiscovery(userToken);
throw error;
}
}
async unsubscribeFromDiscovery(userToken: string): Promise<void> {
const channel = this.discoveryChannel(userToken);
this.handlers.delete(channel);
await this.subscriber.unsubscribe(channel);
}
publishDiscoveryResponse(requestId: string, response: SessionDiscoveryResponse): void {
void this.publisher
.publish(this.discoveryResponseChannel(requestId), JSON.stringify(response))
.catch((error) => this.logger.error(error, "Failed to publish discovery response"));
}
private sessionClaimChannel(userToken: string): string {
return `penpot.mcp.${this.tenant}.session.claim.${this.channelKey(userToken)}`;
}
/**
* Subscribes to the session claims of the given user.
*
* Claims are delivered in publication order, including the subscribing instance's own claims.
*/
async subscribeToSessionClaims(userToken: string, handler: (claim: SessionClaim) => void): Promise<void> {
const channel = this.sessionClaimChannel(userToken);
this.handlers.set(channel, (raw) => {
try {
handler(sessionClaimSchema.parse(JSON.parse(raw)));
} catch (error) {
this.logger.error(error, "Invalid session claim");
}
});
try {
await this.subscriber.subscribe(channel);
} catch (error) {
await this.unsubscribeFromSessionClaims(userToken);
throw error;
}
}
async unsubscribeFromSessionClaims(userToken: string): Promise<void> {
const channel = this.sessionClaimChannel(userToken);
this.handlers.delete(channel);
await this.subscriber.unsubscribe(channel);
}
async publishSessionClaim(userToken: string, claim: SessionClaim): Promise<void> {
await this.publisher.publish(this.sessionClaimChannel(userToken), JSON.stringify(claim));
}
/** Closes both Redis connections. Call on server shutdown. */
async close(): Promise<void> {
await this.subscriber.quit();
await this.publisher.quit();
}
}