From b4659df5b28b473f8191a3f93748ca7a5dfd1adf Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Jul 2026 10:40:33 +0000 Subject: [PATCH] :bug: Preserve established plugin connection when rejecting a duplicate #10961 In multi-user mode, rejecting a second plugin WebSocket connection for an already-registered user token performed the full removeConnection cleanup for the newcomer. Since the token-keyed cleanup is keyed by token rather than by socket, this deleted the clientsByToken entry and the Redis request-channel subscription of the established, healthy connection. That connection then remained open and heartbeating but was unroutable, so every subsequent MCP tool call for the user failed although a valid plugin connection existed. removeConnection now performs the token-keyed cleanup only if the removed connection actually owns the token registration, so rejecting a duplicate releases only the resources the newcomer itself registered. AI-assisted-by: claude-fable-5 --- mcp/packages/server/src/PluginBridge.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 23f6d09e15..e8dda27ac3 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -133,9 +133,10 @@ export class PluginBridge { /** * Removes a client connection and releases all resources associated with it. * - * Clears the per-connection keep-alive interval and removes the connection - * from both the socket-keyed and token-keyed indexes. Safe to call with a - * socket that is not (or no longer) registered. + * Clears the per-connection keep-alive interval and removes the connection from the + * socket-keyed index. The token-keyed index entry (and, in multi-instance mode, the + * token's Redis task subscription) is removed only if it is owned by the given + * connection. Safe to call with a socket that is not (or no longer) registered. * * @param ws - The WebSocket whose connection state should be removed */ @@ -147,12 +148,18 @@ export class PluginBridge { clearInterval(connection.pingInterval); this.connectedClients.delete(ws); if (connection.userToken) { - this.clientsByToken.delete(connection.userToken); + // Perform the token-keyed cleanup only if this connection owns the token registration. + // A connection rejected as a duplicate carries the same token but must not remove token associations. + if (this.clientsByToken.get(connection.userToken) !== connection) { + this.logger.debug("Removed connection does not own its token registration; skipping token cleanup"); + } else { + this.clientsByToken.delete(connection.userToken); - if (this.redisBridge) { - this.redisBridge - .unsubscribeFromTasks(connection.userToken) - .catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel")); + if (this.redisBridge) { + this.redisBridge + .unsubscribeFromTasks(connection.userToken) + .catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel")); + } } } }