mirror of
https://github.com/penpot/penpot.git
synced 2026-06-19 05:42:08 +00:00
✨ 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:
parent
895c9cb8da
commit
71f5c11a11
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user