diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 2695acdd65..8a4ee30f25 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -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); diff --git a/mcp/packages/server/src/RedisBridge.ts b/mcp/packages/server/src/RedisBridge.ts index f6160de956..d8117d9242 100644 --- a/mcp/packages/server/src/RedisBridge.ts +++ b/mcp/packages/server/src/RedisBridge.ts @@ -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 { - 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 { - 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): 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 { - 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 { - const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + const requestChannel = this.requestChannel(userToken); this.handlers.delete(requestChannel); await this.subscriber.unsubscribe(requestChannel); }