penpot/mcp/packages/server/src/RemotePluginTask.ts
Dr. Dominik Jain 03c02d5adf
🎉 Enable multi-instance horizontal scaling for MCP server (#10013)
* 📎 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
2026-06-08 09:53:54 +02:00

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,
});
}
}