Qualify MCP Redis channel names with tenant prefix

Read PENPOT_TENANT env var (defaulting to "default") and embed it in
Redis Pub/Sub channel names as penpot.mcp.<tenant>.task.{req,res}.<id>.

This prevents cross-tenant interference when multiple environments share
a Redis instance, matching the backend convention
(e.g. penpot.rlimit.<tenant>.window.<name> in app.rpc.rlimit).

Co-authored-by: deepseek-v4-flash <deepseek-v4-flash@penpot.app>
This commit is contained in:
Andrey Antukh 2026-06-17 17:32:57 +00:00 committed by Alonso Torres
parent 895c9cb8da
commit 71f5c11a11
2 changed files with 32 additions and 18 deletions

View File

@ -111,12 +111,22 @@ export class PenpotMcpServer {
*/
private readonly redisBridge?: RedisBridge;
/**
* Tenant identifier, read from the `PENPOT_TENANT` environment variable.
*
* Used to qualify Redis channel names so that multiple environments sharing a
* Redis instance do not interfere with each other. Defaults to `"default"`,
* matching the backend default.
*/
private readonly tenant: string;
constructor(private isMultiUser: boolean = false) {
// read port configuration from environment variables
this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost";
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
this.tenant = process.env.PENPOT_TENANT ?? "default";
this.configLoader = new ConfigurationLoader(process.cwd());
this.apiDocs = new ApiDocs();
@ -134,7 +144,7 @@ export class PenpotMcpServer {
// requiring the plugin and the MCP client to connect to the same instance.
const redisUri = process.env.PENPOT_MCP_REDIS_URI;
if (this.isMultiUser && redisUri) {
this.redisBridge = new RedisBridge(redisUri);
this.redisBridge = new RedisBridge(redisUri, this.tenant);
}
this.pluginBridge = new PluginBridge(this, this.webSocketPort, this.redisBridge);

View File

@ -2,16 +2,6 @@ import Redis from "ioredis";
import { PluginTaskRequest, PluginTaskResponse } from "@penpot/mcp-common";
import { createLogger } from "./logger";
/**
* Channel name prefixes for the task request/response pub/sub protocol.
*
* Request channels are keyed by user token (one per connected plugin); response
* channels are keyed by the task ID, so that only the instance that issued a given
* request receives its response.
*/
const TASK_REQUEST_CHANNEL_PREFIX = "penpot.mcp.task.req.";
const TASK_RESPONSE_CHANNEL_PREFIX = "penpot.mcp.task.res.";
/**
* Handler invoked for a task request arriving on a subscribed request channel.
*/
@ -46,6 +36,7 @@ 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.
@ -60,8 +51,11 @@ export class RedisBridge {
* 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) {
constructor(redisUri: string, tenant: string) {
this.tenant = tenant;
this.publisher = new Redis(redisUri);
this.subscriber = new Redis(redisUri);
@ -75,6 +69,16 @@ export class RedisBridge {
});
}
/** Builds the Redis Pub/Sub channel name for a task request addressed to a user token. */
private requestChannel(userToken: string): string {
return `penpot.mcp.${this.tenant}.task.req.${userToken}`;
}
/** 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 token's request channel.
@ -93,8 +97,8 @@ export class RedisBridge {
request: PluginTaskRequest,
onResponse: TaskResponseHandler
): Promise<void> {
const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${request.id}`;
const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`;
const responseChannel = this.responseChannel(request.id);
const requestChannel = this.requestChannel(userToken);
this.handlers.set(responseChannel, (rawMessage) => {
// a response channel is single-use: remove the handler and unsubscribe on delivery
@ -122,7 +126,7 @@ export class RedisBridge {
* @param taskId - The task ID whose response channel to unsubscribe from
*/
async unsubscribeFromResponse(taskId: string): Promise<void> {
const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${taskId}`;
const responseChannel = this.responseChannel(taskId);
this.handlers.delete(responseChannel);
await this.subscriber.unsubscribe(responseChannel);
}
@ -137,7 +141,7 @@ export class RedisBridge {
* @param response - The serialized plugin task response, passed through verbatim
*/
publishTaskResponse(taskId: string, response: PluginTaskResponse<any>): void {
const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${taskId}`;
const responseChannel = this.responseChannel(taskId);
void this.publisher.publish(responseChannel, JSON.stringify(response));
}
@ -150,7 +154,7 @@ export class RedisBridge {
* @param handler - The handler to invoke for incoming requests
*/
async subscribeToTasks(userToken: string, handler: TaskRequestHandler): Promise<void> {
const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`;
const requestChannel = this.requestChannel(userToken);
this.handlers.set(requestChannel, (rawMessage) => {
try {
handler(JSON.parse(rawMessage) as PluginTaskRequest);
@ -167,7 +171,7 @@ export class RedisBridge {
* @param userToken - The user token whose request channel to unsubscribe from
*/
async unsubscribeFromTasks(userToken: string): Promise<void> {
const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`;
const requestChannel = this.requestChannel(userToken);
this.handlers.delete(requestChannel);
await this.subscriber.unsubscribe(requestChannel);
}