mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 20:32:04 +00:00
* 📎 Ignore .iml files (IntelliJ module files) * 🎉 Enable multi-instance horizontal scaling for MCP server Allow the MCP server to run as multiple instances behind a plain round-robin load balancer, removing the previous requirement that a user's plugin WebSocket and MCP client connection terminate on the same instance. Behaviour is unchanged when run as a single instance or without Redis. Cross-instance MCP sessions: when a request arrives with an mcp-session-id that was initialised on another instance, the session is adopted locally instead of rejected. The user token is read from the query parameter (present on every request, as the configured endpoint URL is never rewritten), so no shared session store is needed; the transport is pre-initialised so the SDK's validateSession() accepts it. Cross-instance task routing: when a Redis URI is configured in multi-user mode, plugin task requests are routed via Redis pub/sub keyed by user token. The instance holding a plugin's WebSocket subscribes to that token's request channel; any instance handling a tool call publishes the request and awaits the response on a per-request channel. RedisBridge is a pure transport for the existing serialised PluginTaskRequest/Response objects. PluginTask is split into an abstract base plus a local (promise-backed) PluginTask and a RemotePluginTask whose resolve/reject publish the outcome back over Redis, so the existing local dispatch and response-correlation paths are reused unchanged on the executing instance. Refs #10000
57 lines
2.3 KiB
TypeScript
57 lines
2.3 KiB
TypeScript
import { AbstractPluginTask } from "./PluginTask";
|
|
import { PluginTaskResult } from "@penpot/mcp-common";
|
|
import type { RedisBridge } from "./RedisBridge";
|
|
|
|
/**
|
|
* A plugin task whose outcome is forwarded back to a remote requester via Redis,
|
|
* rather than awaited in-process.
|
|
*
|
|
* This task type is used on the server instance that holds the plugin's WebSocket
|
|
* connection when a task request arrives over Redis (published by another instance
|
|
* that received the corresponding tool call). It is dispatched to the plugin through
|
|
* the ordinary local dispatch path; when the plugin responds, the response-correlation
|
|
* machinery settles this task, and the overridden `resolveWithResult`/`rejectWithError`
|
|
* publish the outcome back onto the requester's Redis response channel.
|
|
*
|
|
* Note that this task has its own ID (used to correlate the local WebSocket dispatch),
|
|
* distinct from the original requester's task ID, which keys the Redis response channel.
|
|
*
|
|
* It deliberately carries no result promise: settling the task *is* the side effect
|
|
* of publishing to Redis, and nothing awaits it locally.
|
|
*/
|
|
export class RemotePluginTask extends AbstractPluginTask<any, PluginTaskResult<any>> {
|
|
/**
|
|
* Creates a task that forwards its outcome to a Redis response channel.
|
|
*
|
|
* @param task - The name of the task to execute (from the incoming request)
|
|
* @param params - The parameters for task execution (from the incoming request)
|
|
* @param redisBridge - The Redis bridge used to publish the outcome
|
|
* @param originalTaskId - The ID of the original request, which keys the response
|
|
* channel the requesting instance is awaiting
|
|
*/
|
|
constructor(
|
|
task: string,
|
|
params: any,
|
|
private readonly redisBridge: RedisBridge,
|
|
private readonly originalTaskId: string
|
|
) {
|
|
super(task, params);
|
|
}
|
|
|
|
resolveWithResult(result: PluginTaskResult<any>): void {
|
|
this.redisBridge.publishTaskResponse(this.originalTaskId, {
|
|
id: this.originalTaskId,
|
|
success: true,
|
|
data: result.data,
|
|
});
|
|
}
|
|
|
|
rejectWithError(error: Error): void {
|
|
this.redisBridge.publishTaskResponse(this.originalTaskId, {
|
|
id: this.originalTaskId,
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|